From 666b3a2ec8e52f818b8cba069bc945add69a5d33 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 13 Jul 2026 21:41:24 -0400 Subject: [PATCH 1/7] =?UTF-8?q?refactor(ml):=20DRY=20pass=20=E2=80=94=20sh?= =?UTF-8?q?ared=20sweep=20helpers=20+=20table-driven=20settings=20(#161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate duplication accrued across the ML tagging + settings backend, behavior-preserving (over-DRY guard applied — the three auto-apply sweep BODIES stay separate; only their shared inner helpers are extracted). - _sigmoid / _conflict_scores / _insert_presentation_review (heads.py): the score→prob transform (6 inlined sites), the presentation conflict signal (2 sites), and the ring-loud PresentationReview insert (2 sites, single- sourced so the mode column can't drift on the shared composite PK). - _applied_or_rejected (training_data.py): the per-tag "applied ∪ rejected" skip-set, byte-identical at 3 sweep sites (heads.py x2, tasks/ml.py ccip). - ccip sweep divergence fixes: import ccip._FIGURE_KINDS + training_data._l2norm instead of local copies that silently drift when the canonical changes. - MLSettings.load / .load_sync classmethods (mirror ImportSettings); route all 8 scalar_one singleton reads through them (the session.get None-path stays). - GET serializers for MLSettings + ImportSettings are now table-driven off the same _EDITABLE tuples PATCH writes, so a new field can't be silently absent from GET (the split that historically dropped fields). - AUTO_APPLY_THRESHOLD_MIN/MAX constant single-sources the [0.5,0.999] operating range across the service clamp + the 5 API validators. - test_ml_dry_helpers.py pins _applied_or_rejected + _sigmoid. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi --- backend/app/api/gpu.py | 4 +- backend/app/api/ml_admin.py | 60 +++------ backend/app/api/settings.py | 31 +---- backend/app/models/ml_settings.py | 12 ++ .../app/services/ml/character_prototypes.py | 8 +- backend/app/services/ml/heads.py | 127 +++++++++--------- backend/app/services/ml/training_data.py | 18 +++ backend/app/tasks/ml.py | 40 ++---- tests/test_ml_dry_helpers.py | 59 ++++++++ 9 files changed, 187 insertions(+), 172 deletions(-) create mode 100644 tests/test_ml_dry_helpers.py diff --git a/backend/app/api/gpu.py b/backend/app/api/gpu.py index 44a7aac..8ee7bb9 100644 --- a/backend/app/api/gpu.py +++ b/backend/app/api/gpu.py @@ -256,9 +256,7 @@ async def lease(): if not await _agent_authed(session): return jsonify({"error": "unauthorized"}), 401 jobs = await GpuJobService(session).lease(agent_id, batch_size=batch) - ml = ( - await session.execute(select(MLSettings).where(MLSettings.id == 1)) - ).scalar_one() + ml = await MLSettings.load(session) # image rows for url/mime in one shot ids = [j.image_record_id for j in jobs] imgs = { diff --git a/backend/app/api/ml_admin.py b/backend/app/api/ml_admin.py index 6afff73..5660090 100644 --- a/backend/app/api/ml_admin.py +++ b/backend/app/api/ml_admin.py @@ -4,6 +4,7 @@ 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") @@ -83,48 +84,21 @@ async def embedder_models(): @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( - { - "cpu_embed_enabled": s.cpu_embed_enabled, - "video_frame_interval_seconds": s.video_frame_interval_seconds, - "video_max_frames": s.video_max_frames, - "embedder_model_version": s.embedder_model_version, - "head_min_positives": s.head_min_positives, - "head_auto_apply_precision": s.head_auto_apply_precision, - "head_auto_apply_enabled": s.head_auto_apply_enabled, - "head_auto_apply_min_positives": s.head_auto_apply_min_positives, - "ccip_match_threshold": s.ccip_match_threshold, - "ccip_auto_apply_enabled": s.ccip_auto_apply_enabled, - "ccip_auto_apply_threshold": s.ccip_auto_apply_threshold, - "presentation_auto_apply_enabled": s.presentation_auto_apply_enabled, - "presentation_auto_apply_threshold": s.presentation_auto_apply_threshold, - "presentation_conflict_threshold": s.presentation_conflict_threshold, - "process_auto_apply_enabled": s.process_auto_apply_enabled, - "process_auto_apply_threshold": s.process_auto_apply_threshold, - "process_conflict_threshold": s.process_conflict_threshold, - "embedder_model_name": s.embedder_model_name, - **{f: getattr(s, f) for f in _DETECTOR_FIELDS}, - } - ) + 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(): - 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() + 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 @@ -154,24 +128,24 @@ def _validate(p: dict) -> str | None: # 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" + 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 (0.5 <= float(p["ccip_match_threshold"]) <= 0.999): - return "ccip_match_threshold must be between 0.5 and 0.999" - if not (0.5 <= float(p["ccip_auto_apply_threshold"]) <= 0.999): - return "ccip_auto_apply_threshold must be between 0.5 and 0.999" + 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 (0.5 <= float(p["presentation_auto_apply_threshold"]) <= 0.999): - return "presentation_auto_apply_threshold must be between 0.5 and 0.999" + 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 (0.5 <= float(p["process_auto_apply_threshold"]) <= 0.999): - return "process_auto_apply_threshold must be between 0.5 and 0.999" + 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" # Embedder model swap (#1190): both must be non-empty. Changing them means a diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index d2d916a..ea76989 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -66,34 +66,9 @@ _EXTDL_TOGGLE_FIELDS = ( async def get_import_settings(): async with get_session() as session: row = await ImportSettings.load(session) - return jsonify({ - "min_width": row.min_width, - "min_height": row.min_height, - "skip_transparent": row.skip_transparent, - "transparency_threshold": row.transparency_threshold, - "skip_single_color": row.skip_single_color, - "single_color_threshold": row.single_color_threshold, - "single_color_tolerance": row.single_color_tolerance, - "phash_threshold": row.phash_threshold, - "download_rate_limit_seconds": row.download_rate_limit_seconds, - "download_validate_files": row.download_validate_files, - "download_schedule_default_seconds": row.download_schedule_default_seconds, - "download_event_retention_days": row.download_event_retention_days, - "download_failure_warning_threshold": row.download_failure_warning_threshold, - "series_suggest_enabled": row.series_suggest_enabled, - "series_suggest_threshold": row.series_suggest_threshold, - "extdl_mega_enabled": row.extdl_mega_enabled, - "extdl_gdrive_enabled": row.extdl_gdrive_enabled, - "extdl_mediafire_enabled": row.extdl_mediafire_enabled, - "extdl_dropbox_enabled": row.extdl_dropbox_enabled, - "extdl_pixeldrain_enabled": row.extdl_pixeldrain_enabled, - "translation_enabled": row.translation_enabled, - "interpreter_base_url": row.interpreter_base_url, - "translation_target_lang": row.translation_target_lang, - "translation_min_confidence": row.translation_min_confidence, - "wip_title_tagging_enabled": row.wip_title_tagging_enabled, - "wip_soft_title_tagging_enabled": row.wip_soft_title_tagging_enabled, - }) + # Table-driven off _EDITABLE_FIELDS (which PATCH also writes) so a new field + # can't be silently absent from GET. + return jsonify({f: getattr(row, f) for f in _EDITABLE_FIELDS}) @settings_bp.route("/settings/import", methods=["PATCH"]) diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py index 574d16e..44aa4f1 100644 --- a/backend/app/models/ml_settings.py +++ b/backend/app/models/ml_settings.py @@ -10,6 +10,7 @@ from sqlalchemy import ( Integer, String, func, + select, ) from sqlalchemy.orm import Mapped, mapped_column @@ -212,3 +213,14 @@ class MLSettings(Base): updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) + + @classmethod + async def load(cls, session) -> "MLSettings": + """The singleton settings row (id=1), via an async session. Mirrors + ImportSettings.load — the shared singleton-loader pattern.""" + return (await session.execute(select(cls).where(cls.id == 1))).scalar_one() + + @classmethod + def load_sync(cls, session) -> "MLSettings": + """The singleton settings row (id=1), via a sync session.""" + return session.execute(select(cls).where(cls.id == 1)).scalar_one() diff --git a/backend/app/services/ml/character_prototypes.py b/backend/app/services/ml/character_prototypes.py index 7a4802a..821abe9 100644 --- a/backend/app/services/ml/character_prototypes.py +++ b/backend/app/services/ml/character_prototypes.py @@ -150,9 +150,7 @@ def refresh_character_prototypes( """Incrementally refresh the prototype store. `full=True` rebuilds every character regardless of the gate/fingerprints (nightly reconcile). Returns {skipped, rebuilt, removed}; commits.""" - settings = session.execute( - select(MLSettings).where(MLSettings.id == 1) - ).scalar_one() + settings = MLSettings.load_sync(session) sig = _global_signature(session) if not full and settings.ccip_ref_signature == sig: return {"skipped": True, "rebuilt": 0, "removed": 0} @@ -204,9 +202,7 @@ def retract_auto_applied_ccip(session: Session) -> int: n_retracted.""" import numpy as np - settings = session.execute( - select(MLSettings).where(MLSettings.id == 1) - ).scalar_one() + settings = MLSettings.load_sync(session) if not settings.ccip_auto_apply_enabled: return 0 thr = float(settings.ccip_auto_apply_threshold) diff --git a/backend/app/services/ml/heads.py b/backend/app/services/ml/heads.py index ae5e7fc..dbaa8cf 100644 --- a/backend/app/services/ml/heads.py +++ b/backend/app/services/ml/heads.py @@ -23,6 +23,7 @@ from datetime import UTC, datetime from typing import Any from sqlalchemy import delete, exists, func, select +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -42,6 +43,7 @@ from ...models import ( from ...models.tag import CHROME_SYSTEM_TAGS, PROCESS_SYSTEM_TAGS, image_tag from .training_data import ( _AUTO_SOURCES, + _applied_or_rejected, _auto_apply_point, _hygiene_excluded_ids, _ids_with_tag, @@ -61,6 +63,14 @@ MIN_POSITIVES_FLOOR = 8 # hard floor; settings.head_min_positives can raise _UNLABELED_POOL = 4000 _EXAMPLES_MIN = 8 # need at least this many embedded +/- to fit a head +# Auto-apply / match confidence operating range. Every graduated auto-apply or +# CCIP-match threshold the operator can set lives in this band, and the head +# precision target is clamped to it: below 0.5 "auto-apply" is meaningless, and +# 1.0 is unachievable so 0.999 is the ceiling. One source shared by the service +# clamp (_normalize_params) and the API validator (ml_admin._validate). +AUTO_APPLY_THRESHOLD_MIN = 0.5 +AUTO_APPLY_THRESHOLD_MAX = 0.999 + # Only these tag kinds get heads (the surfaced suggestion categories). _HEAD_KINDS = (TagKind.general, TagKind.character) # tag.kind -> the suggestion category the rail groups under. @@ -78,6 +88,38 @@ _CATEGORY = {TagKind.general: "general", TagKind.character: "character"} _SYSTEM_TAG_SUGGEST_FLOOR = 0.65 +def _sigmoid(z, np): + """Logistic sigmoid 1/(1+e^-z): the head score→probability transform. One home + for what was inlined at every scoring site (suggest, both sweeps, retract).""" + return 1.0 / (1.0 + np.exp(-z)) + + +def _conflict_scores(Xn, Wc, bc, np): + """The presentation conflict signal (#141): per row, the MAX content-head + probability and WHICH head produced it. Shared by the system-tag sweep's guard-2 + and the soft-wip audit — both ask "does this ALSO look like real content?".""" + cprobs = _sigmoid(Xn @ Wc.T + bc, np) + return cprobs.max(axis=1), cprobs.argmax(axis=1) + + +def _insert_presentation_review( + session, *, image_record_id, tag_id, conflict_tag_id, conflict_score, mode, +): + """Single-source the ring-loud PresentationReview row shape so the two writers + (system-tag sweep guard-2 + soft-wip audit) can't drift on columns or `mode` — + they share the (image_record_id, tag_id) composite PK, so a divergent `mode` + would be a silent first-writer-wins bug.""" + session.execute( + pg_insert(PresentationReview) + .values( + image_record_id=image_record_id, tag_id=tag_id, + conflict_tag_id=conflict_tag_id, conflict_score=conflict_score, + mode=mode, + ) + .on_conflict_do_nothing() + ) + + class HeadTrainingAlreadyRunning(Exception): """Raised by start_head_training_run when a run is already in flight.""" @@ -103,9 +145,7 @@ def start_head_training_run(session: Session, params: dict[str, Any]) -> int: def _settings(session: Session) -> MLSettings: - return session.execute( - select(MLSettings).where(MLSettings.id == 1) - ).scalar_one() + return MLSettings.load_sync(session) def _normalize_params(session: Session, params: dict[str, Any] | None) -> dict[str, Any]: @@ -124,7 +164,7 @@ def _normalize_params(session: Session, params: dict[str, Any] | None) -> dict[s except (TypeError, ValueError): cv_folds = DEFAULT_CV_FOLDS try: - precision_target = min(max(float(params.get("precision_target", s.head_auto_apply_precision)), 0.5), 0.999) + precision_target = min(max(float(params.get("precision_target", s.head_auto_apply_precision)), AUTO_APPLY_THRESHOLD_MIN), AUTO_APPLY_THRESHOLD_MAX) except (TypeError, ValueError): precision_target = s.head_auto_apply_precision return { @@ -536,7 +576,7 @@ async def score_image( norms[norms == 0] = 1.0 Xn = X / norms Z = Xn @ heads["W"].T + heads["b"] # (B, H) - probs_bag = 1.0 / (1.0 + np.exp(-Z)) # (B, H) + probs_bag = _sigmoid(Z, np) # (B, H) probs = probs_bag.max(axis=0) # (H,) best over the bag # ARGMAX beside the max: WHICH bag row won each head → the region that grounds # the tag (bag_meta[win]); None when the whole-image vector won (#1206). @@ -614,9 +654,7 @@ async def ground_applied_tag( async def _settings_async(session: AsyncSession) -> MLSettings: - return ( - await session.execute(select(MLSettings).where(MLSettings.id == 1)) - ).scalar_one() + return await MLSettings.load(session) # --- Earned auto-apply (sync, ml worker) --------------------------------- @@ -687,7 +725,6 @@ def auto_apply_sweep( embeddings in chunks; commits per chunk on a real run. Returns {n_applied, concepts:[{tag_id,name,applied,scanned,threshold}]}.""" import numpy as np - from sqlalchemy.dialects.postgresql import insert as pg_insert settings = _settings(session) rows = _auto_apply_heads( @@ -704,18 +741,7 @@ def auto_apply_sweep( names = [r.name for r in rows] # Skip images that already carry, or have rejected, each tag. - skip = {tid: set() for tid in tag_ids} - for tid in tag_ids: - for (iid,) in session.execute( - select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid) - ): - skip[tid].add(iid) - for (iid,) in session.execute( - select(TagSuggestionRejection.image_record_id).where( - TagSuggestionRejection.tag_id == tid - ) - ): - skip[tid].add(iid) + skip = _applied_or_rejected(session, tag_ids) applied = [0] * len(rows) scanned = 0 @@ -729,7 +755,7 @@ def auto_apply_sweep( if not cids: continue Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np) - probs = 1.0 / (1.0 + np.exp(-(Xn @ W.T + b))) # (N, H) + probs = _sigmoid(Xn @ W.T + b, np) # (N, H) scanned += len(cids) for h in range(len(rows)): tid = tag_ids[h] @@ -840,7 +866,6 @@ def system_tag_auto_apply_sweep( enabled flag is set. numpy-only (no sklearn). Returns {n_applied, n_flagged, concepts}.""" import numpy as np - from sqlalchemy.dialects.postgresql import insert as pg_insert cfg = _SWEEP_MODES[mode] settings = _settings(session) @@ -869,18 +894,7 @@ def system_tag_auto_apply_sweep( valued = _valued_image_ids(session) # Skip images that already carry, or have rejected, each presentation tag. - skip = {tid: set() for tid in pres_tag_ids} - for tid in pres_tag_ids: - for (iid,) in session.execute( - select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid) - ): - skip[tid].add(iid) - for (iid,) in session.execute( - select(TagSuggestionRejection.image_record_id).where( - TagSuggestionRejection.tag_id == tid - ) - ): - skip[tid].add(iid) + skip = _applied_or_rejected(session, pres_tag_ids) applied = [0] * len(pres) n_flagged = 0 @@ -895,11 +909,9 @@ def system_tag_auto_apply_sweep( if not cids: continue Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np) - probs = 1.0 / (1.0 + np.exp(-(Xn @ Wp.T + bp))) # (N, P) + probs = _sigmoid(Xn @ Wp.T + bp, np) # (N, P) if Wc is not None: - cprobs = 1.0 / (1.0 + np.exp(-(Xn @ Wc.T + bc))) # (N, C) - max_c = cprobs.max(axis=1) - arg_c = cprobs.argmax(axis=1) + max_c, arg_c = _conflict_scores(Xn, Wc, bc, np) # (N,), (N,) scanned += len(cids) for p in range(len(pres)): tid = pres_tag_ids[p] @@ -924,15 +936,12 @@ def system_tag_auto_apply_sweep( if Wc is not None and float(max_c[idx]) >= conflict_thr: n_flagged += 1 if not dry_run: - session.execute( - pg_insert(PresentationReview) - .values( - image_record_id=iid, tag_id=tid, - conflict_tag_id=conf_tag_ids[int(arg_c[idx])], - conflict_score=float(max_c[idx]), - mode=mode, - ) - .on_conflict_do_nothing() + _insert_presentation_review( + session, + image_record_id=iid, tag_id=tid, + conflict_tag_id=conf_tag_ids[int(arg_c[idx])], + conflict_score=float(max_c[idx]), + mode=mode, ) if not dry_run: session.commit() @@ -956,7 +965,6 @@ def soft_wip_conflict_audit(session: Session, dry_run: bool = False) -> dict: NOT remove the tag; the operator decides. No-op when there are no content heads. numpy-only. Returns {n_scanned, n_flagged}.""" import numpy as np - from sqlalchemy.dialects.postgresql import insert as pg_insert from ..wip_title import WIP_TITLE_SOFT_SOURCE, resolve_wip_tag_id @@ -993,22 +1001,17 @@ def soft_wip_conflict_audit(session: Session, dry_run: bool = False) -> dict: continue scanned += len(cids) Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np) - cprobs = 1.0 / (1.0 + np.exp(-(Xn @ Wc.T + bc))) - max_c = cprobs.max(axis=1) - arg_c = cprobs.argmax(axis=1) + max_c, arg_c = _conflict_scores(Xn, Wc, bc, np) for k in range(len(cids)): if float(max_c[k]) >= conflict_thr: n_flagged += 1 if not dry_run: - session.execute( - pg_insert(PresentationReview) - .values( - image_record_id=cids[k], tag_id=wip_id, - conflict_tag_id=conf_tag_ids[int(arg_c[k])], - conflict_score=float(max_c[k]), - mode="process", - ) - .on_conflict_do_nothing() + _insert_presentation_review( + session, + image_record_id=cids[k], tag_id=wip_id, + conflict_tag_id=conf_tag_ids[int(arg_c[k])], + conflict_score=float(max_c[k]), + mode="process", ) if not dry_run: session.commit() @@ -1062,7 +1065,7 @@ def retract_auto_applied_heads(session: Session) -> int: continue Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np) w = np.asarray(weights, dtype=np.float32) - probs = 1.0 / (1.0 + np.exp(-(Xn @ w + float(bias)))) + probs = _sigmoid(Xn @ w + float(bias), np) below = [cids[k] for k in np.where(probs < float(thr))[0]] for iid in below: session.execute( diff --git a/backend/app/services/ml/training_data.py b/backend/app/services/ml/training_data.py index b88ebbd..bce0e58 100644 --- a/backend/app/services/ml/training_data.py +++ b/backend/app/services/ml/training_data.py @@ -94,6 +94,24 @@ def _rejected_ids(session: Session, tag_id: int) -> list[int]: ] +def _applied_or_rejected(session: Session, tag_ids) -> dict[int, set[int]]: + """Per-tag skip set for the auto-apply sweeps: every image that ALREADY carries + the tag (ANY source — not just training positives) OR has rejected it. A sweep + never re-applies to these. Shared by auto_apply_sweep + system_tag_auto_apply_sweep + (heads.py) and scheduled_ccip_auto_apply (tasks/ml.py). Callers mutate the returned + sets in-place to also dedupe within a single run.""" + skip: dict[int, set[int]] = {} + for tid in tag_ids: + ids = { + r[0] for r in session.execute( + select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid) + ).all() + } + ids.update(_rejected_ids(session, tid)) + skip[tid] = ids + return skip + + def _sample_unlabeled(session: Session, exclude: set[int], limit: int) -> list[int]: """Random image ids (with an embedding) NOT carrying the tag. Concepts are sparse, so an untagged image is almost always a true negative.""" diff --git a/backend/app/tasks/ml.py b/backend/app/tasks/ml.py index f25d0fe..958b57c 100644 --- a/backend/app/tasks/ml.py +++ b/backend/app/tasks/ml.py @@ -105,9 +105,7 @@ def embed_image(self, image_id: int) -> dict: record = session.get(ImageRecord, image_id) if record is None: return {"status": "missing", "image_id": image_id} - settings = session.execute( - select(MLSettings).where(MLSettings.id == 1) - ).scalar_one() + settings = MLSettings.load_sync(session) src = Path(record.path) is_vid = _is_video(src) @@ -488,15 +486,10 @@ def scheduled_ccip_auto_apply() -> str: from sqlalchemy import select as sa_select from sqlalchemy.dialects.postgresql import insert as pg_insert - from ..models import ImageRegion, MLSettings, Tag, TagKind, TagSuggestionRejection + from ..models import ImageRegion, MLSettings, Tag, TagKind from ..models.tag import image_tag - - fig = ("face", "figure") - - def _l2(m): - n = np.linalg.norm(m, axis=1, keepdims=True) - n[n == 0] = 1.0 - return m / n + from ..services.ml.ccip import _FIGURE_KINDS + from ..services.ml.training_data import _applied_or_rejected, _l2norm SessionLocal = _sync_session_factory() with SessionLocal() as session: @@ -521,7 +514,7 @@ def scheduled_ccip_auto_apply() -> str: ) .join(Tag, Tag.id == image_tag.c.tag_id) .where(Tag.kind == TagKind.character) - .where(ImageRegion.kind.in_(fig)) + .where(ImageRegion.kind.in_(_FIGURE_KINDS)) .where(ImageRegion.ccip_embedding.is_not(None)) .where(ImageRegion.image_record_id.in_(single)) ).all() @@ -532,29 +525,16 @@ def scheduled_ccip_auto_apply() -> str: for tid, vec in ref_rows: by_char.setdefault(tid, []).append(vec) ref_tags = list(by_char) - mats = [_l2(np.asarray(by_char[t], dtype=np.float32)) for t in ref_tags] + mats = [_l2norm(np.asarray(by_char[t], dtype=np.float32), np) for t in ref_tags] allref = np.vstack(mats) # (total, 768) seg = np.cumsum([0] + [len(m) for m in mats])[:-1] # per-char start # Per character: images that already carry OR rejected the tag — skip. - skip = {t: set() for t in ref_tags} - for t in ref_tags: - for (iid,) in session.execute( - sa_select(image_tag.c.image_record_id).where( - image_tag.c.tag_id == t - ) - ): - skip[t].add(iid) - for (iid,) in session.execute( - sa_select(TagSuggestionRejection.image_record_id).where( - TagSuggestionRejection.tag_id == t - ) - ): - skip[t].add(iid) + skip = _applied_or_rejected(session, ref_tags) img_ids = list(session.execute( sa_select(ImageRegion.image_record_id) - .where(ImageRegion.kind.in_(fig), ImageRegion.ccip_embedding.is_not(None)) + .where(ImageRegion.kind.in_(_FIGURE_KINDS), ImageRegion.ccip_embedding.is_not(None)) .distinct() ).scalars()) @@ -566,7 +546,7 @@ def scheduled_ccip_auto_apply() -> str: sa_select(ImageRegion.image_record_id, ImageRegion.ccip_embedding) .where( ImageRegion.image_record_id.in_(chunk), - ImageRegion.kind.in_(fig), + ImageRegion.kind.in_(_FIGURE_KINDS), ImageRegion.ccip_embedding.is_not(None), ) ).all() @@ -574,7 +554,7 @@ def scheduled_ccip_auto_apply() -> str: for iid, vec in rows: by_img.setdefault(iid, []).append(vec) for iid, vecs in by_img.items(): - q = _l2(np.asarray(vecs, dtype=np.float32)) # (nq, 768) + q = _l2norm(np.asarray(vecs, dtype=np.float32), np) # (nq, 768) colmax = (q @ allref.T).max(axis=0) # (total,) charmax = np.maximum.reduceat(colmax, seg) # (n_chars,) for ci in np.where(charmax >= thr)[0]: diff --git a/tests/test_ml_dry_helpers.py b/tests/test_ml_dry_helpers.py new file mode 100644 index 0000000..bff8d27 --- /dev/null +++ b/tests/test_ml_dry_helpers.py @@ -0,0 +1,59 @@ +"""Shared ML helpers extracted in the DRY pass (milestone #161). These pin the +single sources the auto-apply sweeps now trust, so a future edit can't silently +drift them: `_applied_or_rejected` is the skip-set used by auto_apply_sweep, +system_tag_auto_apply_sweep (heads.py) and scheduled_ccip_auto_apply (tasks/ml.py); +`_sigmoid` is the head score→prob transform used at every scoring site.""" +import pytest + +from backend.app.models import ImageRecord, Tag, TagKind, TagSuggestionRejection +from backend.app.models.tag import image_tag +from backend.app.services.ml.training_data import _applied_or_rejected + + +def test_sigmoid_matches_naive_form(): + import numpy as np + + from backend.app.services.ml.heads import _sigmoid + + z = np.array([-3.0, -0.5, 0.0, 1.5, 12.0], dtype=np.float32) + assert np.allclose(_sigmoid(z, np), 1.0 / (1.0 + np.exp(-z))) + assert float(_sigmoid(np.array([0.0]), np)[0]) == pytest.approx(0.5) + + +@pytest.mark.integration +def test_applied_or_rejected_unions_applied_any_source_and_rejected(db_sync): + a = Tag(name="dry-helper-a", kind=TagKind.general) + b = Tag(name="dry-helper-b", kind=TagKind.general) + db_sync.add_all([a, b]) + db_sync.flush() + + imgs = [] + for i in range(5): + img = ImageRecord( + path=f"/images/dryhelp{i}.jpg", sha256=f"{i:064d}", size_bytes=1, + mime="image/jpeg", width=1, height=1, origin="imported_filesystem", + integrity_status="unknown", siglip_embedding=[0.0] * 1152, + ) + db_sync.add(img) + imgs.append(img) + db_sync.flush() + + # tag a: applied manually (img0), applied by an AUTO source (img1), rejected (img2). + db_sync.execute(image_tag.insert().values( + image_record_id=imgs[0].id, tag_id=a.id, source="manual")) + db_sync.execute(image_tag.insert().values( + image_record_id=imgs[1].id, tag_id=a.id, source="head_auto")) + db_sync.add(TagSuggestionRejection(image_record_id=imgs[2].id, tag_id=a.id)) + # tag b: applied to img3 only. + db_sync.execute(image_tag.insert().values( + image_record_id=imgs[3].id, tag_id=b.id, source="manual")) + db_sync.flush() + + skip = _applied_or_rejected(db_sync, [a.id, b.id]) + + # Applied-under-ANY-source (manual + head_auto) ∪ rejected, kept per-tag; the + # untouched image (img4) appears under neither tag. + assert skip[a.id] == {imgs[0].id, imgs[1].id, imgs[2].id} + assert skip[b.id] == {imgs[3].id} + assert imgs[4].id not in skip[a.id] + assert imgs[4].id not in skip[b.id] From c87f8a1bb35a44549d43781ff8507aa28b0c968f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 13 Jul 2026 21:41:34 -0400 Subject: [PATCH 2/7] refactor(maintenance): DRY the recover_stalled head-run twins (#161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recover_stalled_head_training_runs and recover_stalled_head_auto_apply_runs were near-exact copies (coalesce-flip + keep-last-N prune, differing only in model + two constants). Extract _recover_stalled_runs(model, stall_minutes, keep_runs, label); the two tasks become thin wrappers. The other two recover tasks are deliberately NOT folded in (library-audit has no prune tail; backup uses a single started_at cutoff). test_recover_stalled_head_runs.py covers both wrappers (stalled→error, fresh survives) — previously untested. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi --- backend/app/tasks/maintenance.py | 117 +++++++++--------------- tests/test_recover_stalled_head_runs.py | 63 +++++++++++++ 2 files changed, 108 insertions(+), 72 deletions(-) create mode 100644 tests/test_recover_stalled_head_runs.py diff --git a/backend/app/tasks/maintenance.py b/backend/app/tasks/maintenance.py index 2ad0b3f..67432c2 100644 --- a/backend/app/tasks/maintenance.py +++ b/backend/app/tasks/maintenance.py @@ -776,89 +776,62 @@ def recover_stalled_library_audit_runs() -> int: return recovered +def _recover_stalled_runs(model, *, stall_minutes: int, keep_runs: int, label: str) -> int: + """Shared recovery + retention sweep for the head run-tracking tables + (HeadTrainingRun / HeadAutoApplyRun, which share the + status/last_progress_at/started_at/finished_at/error/id columns): flip 'running' + rows with no progress past `stall_minutes` to 'error', then prune to the last + `keep_runs` (rule 89). Returns the number recovered. NOTE the two other recover + tasks are deliberately NOT folded in — library-audit has no prune tail and + backup uses a single started_at cutoff.""" + SessionLocal = _sync_session_factory() + now = datetime.now(UTC) + cutoff = now - timedelta(minutes=stall_minutes) + with SessionLocal() as session: + result = session.execute( + update(model) + .where(model.status == "running") + .where(func.coalesce(model.last_progress_at, model.started_at) < cutoff) + .values( + status="error", finished_at=now, + error=f"stranded by recovery sweep (no progress for {stall_minutes} min)", + ) + ) + keep = session.execute( + select(model.id).order_by(model.id.desc()).limit(keep_runs) + ).scalars().all() + if keep: + session.execute(delete(model).where(model.id.not_in(keep))) + session.commit() + recovered = result.rowcount or 0 + if recovered: + log.info("%s: recovered %d rows", label, recovered) + return recovered + + @celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_training_runs") def recover_stalled_head_training_runs() -> int: """Flip HeadTrainingRun rows stuck in 'running' past the stall threshold to 'error', and prune old runs to the last HEAD_TRAINING_KEEP_RUNS (retention, rule 89). Runs every 5 min on the maintenance lane; no-op when idle.""" - SessionLocal = _sync_session_factory() - now = datetime.now(UTC) - cutoff = now - timedelta(minutes=HEAD_TRAINING_STALL_THRESHOLD_MINUTES) - with SessionLocal() as session: - result = session.execute( - update(HeadTrainingRun) - .where(HeadTrainingRun.status == "running") - .where( - func.coalesce( - HeadTrainingRun.last_progress_at, HeadTrainingRun.started_at - ) - < cutoff - ) - .values( - status="error", finished_at=now, - error=( - f"stranded by recovery sweep (no progress for " - f"{HEAD_TRAINING_STALL_THRESHOLD_MINUTES} min)" - ), - ) - ) - keep = session.execute( - select(HeadTrainingRun.id).order_by(HeadTrainingRun.id.desc()) - .limit(HEAD_TRAINING_KEEP_RUNS) - ).scalars().all() - if keep: - session.execute( - delete(HeadTrainingRun).where(HeadTrainingRun.id.not_in(keep)) - ) - session.commit() - recovered = result.rowcount or 0 - if recovered: - log.info( - "recover_stalled_head_training_runs: recovered %d rows", recovered - ) - return recovered + return _recover_stalled_runs( + HeadTrainingRun, + stall_minutes=HEAD_TRAINING_STALL_THRESHOLD_MINUTES, + keep_runs=HEAD_TRAINING_KEEP_RUNS, + label="recover_stalled_head_training_runs", + ) @celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_auto_apply_runs") def recover_stalled_head_auto_apply_runs() -> int: """Flip stalled HeadAutoApplyRun 'running' rows to 'error' + prune to the last HEAD_AUTO_APPLY_KEEP_RUNS (retention, rule 89). 5-min maintenance lane.""" - SessionLocal = _sync_session_factory() - now = datetime.now(UTC) - cutoff = now - timedelta(minutes=HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES) - with SessionLocal() as session: - result = session.execute( - update(HeadAutoApplyRun) - .where(HeadAutoApplyRun.status == "running") - .where( - func.coalesce( - HeadAutoApplyRun.last_progress_at, HeadAutoApplyRun.started_at - ) - < cutoff - ) - .values( - status="error", finished_at=now, - error=( - f"stranded by recovery sweep (no progress for " - f"{HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES} min)" - ), - ) - ) - keep = session.execute( - select(HeadAutoApplyRun.id).order_by(HeadAutoApplyRun.id.desc()) - .limit(HEAD_AUTO_APPLY_KEEP_RUNS) - ).scalars().all() - if keep: - session.execute( - delete(HeadAutoApplyRun).where(HeadAutoApplyRun.id.not_in(keep)) - ) - session.commit() - recovered = result.rowcount or 0 - if recovered: - log.info( - "recover_stalled_head_auto_apply_runs: recovered %d rows", recovered - ) - return recovered + return _recover_stalled_runs( + HeadAutoApplyRun, + stall_minutes=HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES, + keep_runs=HEAD_AUTO_APPLY_KEEP_RUNS, + label="recover_stalled_head_auto_apply_runs", + ) # Keep ~6 months of daily head-metric snapshots (enough to see tuning trends). diff --git a/tests/test_recover_stalled_head_runs.py b/tests/test_recover_stalled_head_runs.py new file mode 100644 index 0000000..778e491 --- /dev/null +++ b/tests/test_recover_stalled_head_runs.py @@ -0,0 +1,63 @@ +"""recover_stalled_head_training_runs + recover_stalled_head_auto_apply_runs share +one helper (_recover_stalled_runs, DRY pass #161). These pin BOTH wrappers so the +shared source stays correct: a 'running' row with no progress past the stall +threshold flips to 'error'; a fresh 'running' row is left alone.""" +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import select + +from backend.app.models import HeadAutoApplyRun, HeadTrainingRun + +pytestmark = pytest.mark.integration + + +def test_recover_stalled_head_training_runs_flips_stalled_keeps_fresh(db_sync): + from backend.app.tasks.maintenance import recover_stalled_head_training_runs + + stale = HeadTrainingRun( + params={}, status="running", + last_progress_at=datetime.now(UTC) - timedelta(days=1), + ) + fresh = HeadTrainingRun( + params={}, status="running", last_progress_at=datetime.now(UTC), + ) + db_sync.add_all([stale, fresh]) + db_sync.commit() + stale_id, fresh_id = stale.id, fresh.id + + assert recover_stalled_head_training_runs.apply().get() == 1 + + db_sync.expire_all() + assert db_sync.execute( + select(HeadTrainingRun.status).where(HeadTrainingRun.id == stale_id) + ).scalar_one() == "error" + assert db_sync.execute( + select(HeadTrainingRun.status).where(HeadTrainingRun.id == fresh_id) + ).scalar_one() == "running" + + +def test_recover_stalled_head_auto_apply_runs_flips_stalled_keeps_fresh(db_sync): + from backend.app.tasks.maintenance import recover_stalled_head_auto_apply_runs + + stale = HeadAutoApplyRun( + dry_run=False, params={}, status="running", + last_progress_at=datetime.now(UTC) - timedelta(days=1), + ) + fresh = HeadAutoApplyRun( + dry_run=False, params={}, status="running", + last_progress_at=datetime.now(UTC), + ) + db_sync.add_all([stale, fresh]) + db_sync.commit() + stale_id, fresh_id = stale.id, fresh.id + + assert recover_stalled_head_auto_apply_runs.apply().get() == 1 + + db_sync.expire_all() + assert db_sync.execute( + select(HeadAutoApplyRun.status).where(HeadAutoApplyRun.id == stale_id) + ).scalar_one() == "error" + assert db_sync.execute( + select(HeadAutoApplyRun.status).where(HeadAutoApplyRun.id == fresh_id) + ).scalar_one() == "running" From 099e1e664c61c483c1ec0565b0a187eae6d2729a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 13 Jul 2026 21:41:34 -0400 Subject: [PATCH 3/7] refactor(patreon): DRY the campaigns-API request (#161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _lookup_via_api and resolve_display_name shared ~90% of their body (same endpoint, params, headers, error handling — differing only in which field they pluck from data[0]). Extract _campaigns_api_first(vanity, cookies_path) -> dict|None; callers pluck the campaign id vs the display name. Return-value behavior preserved (the display-name path additionally gains the helper's more granular warning logs). Covered by the existing test_patreon_resolver.py. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi --- backend/app/services/patreon_resolver.py | 56 +++++++++--------------- 1 file changed, 20 insertions(+), 36 deletions(-) diff --git a/backend/app/services/patreon_resolver.py b/backend/app/services/patreon_resolver.py index f4dc3ef..b4e9300 100644 --- a/backend/app/services/patreon_resolver.py +++ b/backend/app/services/patreon_resolver.py @@ -91,48 +91,46 @@ def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None: ) -def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None: +def _campaigns_api_first(vanity: str, cookies_path: str | None) -> dict | None: + """The first `data` object from Patreon's campaigns API filtered by vanity + (`?filter[vanity]=&fields[campaign]=name`), or None on any failure + (network / non-200 / non-JSON / empty). The single request shape shared by + _lookup_via_api (plucks the campaign id) and resolve_display_name (plucks the + display name).""" jar = _load_cookie_jar(cookies_path) - headers = { - "User-Agent": _USER_AGENT, - "Accept": "application/vnd.api+json", - } - params = { - "filter[vanity]": vanity, - "fields[campaign]": "name", - } try: resp = requests.get( _CAMPAIGNS_URL, - params=params, - headers=headers, + params={"filter[vanity]": vanity, "fields[campaign]": "name"}, + headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"}, cookies=jar, timeout=_TIMEOUT_SECONDS, ) except requests.RequestException as exc: log.warning("Patreon campaigns API request failed for vanity=%s: %s", vanity, exc) return None - if resp.status_code != 200: log.warning( "Patreon campaigns API returned HTTP %d for vanity=%s", resp.status_code, vanity, ) return None - try: payload = resp.json() except ValueError as exc: log.warning("Patreon campaigns API returned non-JSON for vanity=%s: %s", vanity, exc) return None + data = payload.get("data") if isinstance(payload, dict) else None + if not isinstance(data, list) or not data or not isinstance(data[0], dict): + return None + return data[0] - if not isinstance(payload, dict): + +def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None: + first = _campaigns_api_first(vanity, cookies_path) + if first is None: return None - data = payload.get("data") - if not isinstance(data, list) or not data: - return None - first = data[0] if isinstance(data[0], dict) else None - campaign_id = first.get("id") if first else None + campaign_id = first.get("id") if not isinstance(campaign_id, str) or not campaign_id: return None log.info("Resolved Patreon vanity=%s → campaign_id=%s", vanity, campaign_id) @@ -144,24 +142,10 @@ def resolve_display_name(vanity: str, cookies_path: str | None) -> str | None: (`fields[campaign]=name`), used to name the Artist at add-time (#130). None on any failure — the caller falls back to the vanity handle. Sync: call from an executor.""" - jar = _load_cookie_jar(cookies_path) - try: - resp = requests.get( - _CAMPAIGNS_URL, - params={"filter[vanity]": vanity, "fields[campaign]": "name"}, - headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"}, - cookies=jar, - timeout=_TIMEOUT_SECONDS, - ) - if resp.status_code != 200: - return None - data = resp.json().get("data") - except (requests.RequestException, ValueError) as exc: - log.warning("Patreon name lookup failed for vanity=%s: %s", vanity, exc) + first = _campaigns_api_first(vanity, cookies_path) + if first is None: return None - if not isinstance(data, list) or not data or not isinstance(data[0], dict): - return None - name = (data[0].get("attributes") or {}).get("name") + name = (first.get("attributes") or {}).get("name") return name.strip() if isinstance(name, str) and name.strip() else None From 05df51b749ee985bfbea7da2e91ecbd4e066d597 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 13 Jul 2026 21:49:30 -0400 Subject: [PATCH 4/7] fix(ml): drop unnecessary quotes on MLSettings.load annotations (UP037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python 3.14 evaluates annotations lazily, so the self-referential return type needs no forward-ref quotes — matches ImportSettings.load. Fixes the ruff lint lane on the DRY-pass push. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi --- backend/app/models/ml_settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py index 44aa4f1..72da17b 100644 --- a/backend/app/models/ml_settings.py +++ b/backend/app/models/ml_settings.py @@ -215,12 +215,12 @@ class MLSettings(Base): ) @classmethod - async def load(cls, session) -> "MLSettings": + async def load(cls, session) -> MLSettings: """The singleton settings row (id=1), via an async session. Mirrors ImportSettings.load — the shared singleton-loader pattern.""" return (await session.execute(select(cls).where(cls.id == 1))).scalar_one() @classmethod - def load_sync(cls, session) -> "MLSettings": + def load_sync(cls, session) -> MLSettings: """The singleton settings row (id=1), via a sync session.""" return session.execute(select(cls).where(cls.id == 1)).scalar_one() From a2d1ed935d47786d018cdf7279a5bd156607af1a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 13 Jul 2026 21:51:23 -0400 Subject: [PATCH 5/7] refactor(ui): DRY the settings-card CSS tokens + fix unstyled headers (#161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Promote .fc-section-h to a global token (app.css). It was copied identically into 4 cards, and TranslationCard used the class with NO local def — so its section headers rendered unstyled. Now fixed everywhere. - Promote .fc-good / .fc-weak status colours to globals; delete the local copies in the GPU/heads cards. (.fc-ok stays local — divergent: on-surface in HeadsCard vs success in QueuesTable. .fc-bad stays — different name.) - Delete 10 identical local .fc-muted redefinitions that crept back after the 2026-06-09 sweep; the global utility already covers them. - DbMaintenanceCard: opacity:0.6 muted text → the .fc-muted token (the exact anti-pattern that token's comment forbids). - HeadsCard: collapse byte-identical ratePct() into pct(). CSS-only + one template class swap; no logic change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi --- .../src/components/settings/CropProposersCard.vue | 5 ----- .../src/components/settings/DangerZoneCard.vue | 1 - .../src/components/settings/DbMaintenanceCard.vue | 2 +- .../components/settings/DownloadsActivityPanel.vue | 1 - .../src/components/settings/GpuActivityPanel.vue | 2 -- frontend/src/components/settings/GpuAgentCard.vue | 7 ------- frontend/src/components/settings/GpuTriageCard.vue | 7 ------- frontend/src/components/settings/HeadsCard.vue | 11 +---------- .../src/components/settings/MLBackfillCard.vue | 1 - frontend/src/styles/app.css | 14 ++++++++++++++ frontend/src/views/ExploreView.vue | 1 - frontend/src/views/TagsView.vue | 1 - 12 files changed, 16 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/settings/CropProposersCard.vue b/frontend/src/components/settings/CropProposersCard.vue index 46edd54..64bb570 100644 --- a/frontend/src/components/settings/CropProposersCard.vue +++ b/frontend/src/components/settings/CropProposersCard.vue @@ -131,11 +131,6 @@ function saveToggle (p, v) { diff --git a/frontend/src/components/settings/GpuActivityPanel.vue b/frontend/src/components/settings/GpuActivityPanel.vue index 7378309..39bba29 100644 --- a/frontend/src/components/settings/GpuActivityPanel.vue +++ b/frontend/src/components/settings/GpuActivityPanel.vue @@ -95,7 +95,6 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) }) diff --git a/frontend/src/components/settings/GpuAgentCard.vue b/frontend/src/components/settings/GpuAgentCard.vue index b9a8492..fca73fa 100644 --- a/frontend/src/components/settings/GpuAgentCard.vue +++ b/frontend/src/components/settings/GpuAgentCard.vue @@ -367,11 +367,6 @@ async function onReprocess() { diff --git a/frontend/src/components/settings/GpuTriageCard.vue b/frontend/src/components/settings/GpuTriageCard.vue index a2ab8ac..6f24c38 100644 --- a/frontend/src/components/settings/GpuTriageCard.vue +++ b/frontend/src/components/settings/GpuTriageCard.vue @@ -155,11 +155,6 @@ async function onRecover(it) { diff --git a/frontend/src/components/settings/MLBackfillCard.vue b/frontend/src/components/settings/MLBackfillCard.vue index 3becc59..aca226a 100644 --- a/frontend/src/components/settings/MLBackfillCard.vue +++ b/frontend/src/components/settings/MLBackfillCard.vue @@ -80,5 +80,4 @@ async function run() { diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index e9f9552..3e24a75 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -40,6 +40,20 @@ emits, so no specificity/reorder fight — no !important needed. */ .fc-muted { color: rgb(var(--v-theme-on-surface-variant)); } +/* Section sub-heading in settings cards (DRY pass #161): was redefined + identically in 4 cards, and TranslationCard used the class with NO local def + so its section headers rendered unstyled. Now one global utility. */ +.fc-section-h { + font-size: 13px; font-weight: 700; letter-spacing: 0.03em; + text-transform: uppercase; color: rgb(var(--v-theme-on-surface)); +} + +/* Status text colours (DRY pass #161): fc-good = success, fc-weak = error, + consolidated from the GPU / heads cards. fc-ok is intentionally NOT global — + it means on-surface in HeadsCard but success in QueuesTable. */ +.fc-good { color: rgb(var(--v-theme-success)); } +.fc-weak { color: rgb(var(--v-theme-error)); } + /* Vuetify 4 dropped its global CSS reset (normalisation moved into each component). FC's layouts assumed the reset zeroed margins on text elements, so restore just that — the "minimal reset" from the v4 upgrade guide — inside diff --git a/frontend/src/views/ExploreView.vue b/frontend/src/views/ExploreView.vue index e352b99..611c95d 100644 --- a/frontend/src/views/ExploreView.vue +++ b/frontend/src/views/ExploreView.vue @@ -284,7 +284,6 @@ onUnmounted(() => {