refactor(ml): remove the dead per-tag centroid subsystem (#1189)
The v2 pivot replaced per-tag SigLIP centroids with learned heads + CCIP. Centroids were still recomputed (on every tag merge + a daily beat) but NOTHING read them — suggestions come from heads+CCIP and apply_allowlist_tags applies via Camie predictions, not centroids. Pure dead wiring; remove it. Removed: CentroidService, recompute_centroid/recompute_centroids tasks, the daily beat, POST /api/ml/recompute-centroids, the recompute-on-merge trigger, the tag_reference_embedding table + model, the centroid_similarity_threshold + min_reference_images settings (migration 0066), the CentroidRecomputeCard + its store action + MaintenancePanel tile, and the centroid slider in MLThresholdSliders. _keep_as_alias drops its vestigial has-centroid branch (the allowlist branch already covers "could re-emit"); tag merge no longer clears a table that no longer exists. NOT touched (still live, parallel to heads): the Camie tagger, ImagePrediction, and the allowlist bulk-apply — accepting a suggestion still allowlists + applies it across the library. The tag-eval "centroid" baseline metric is unrelated (in-memory) and stays. (image_record.centroid_scores JSON column also remains — separate legacy field, its own micro-cleanup.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
"""drop the dead per-tag centroid subsystem (#1189 cleanup)
|
||||||
|
|
||||||
|
The v2 pivot replaced per-tag SigLIP centroids with learned heads + CCIP.
|
||||||
|
Nothing read the centroids anymore — they were recomputed (on merge + a daily
|
||||||
|
beat) but never consumed for suggestions or auto-apply. Remove the storage +
|
||||||
|
its two now-unused settings columns. (The recompute tasks, beat, endpoint,
|
||||||
|
service, and UI card are removed in the same change.)
|
||||||
|
|
||||||
|
Revision ID: 0066
|
||||||
|
Revises: 0065
|
||||||
|
Create Date: 2026-06-30
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0066"
|
||||||
|
down_revision: Union[str, None] = "0065"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_table("tag_reference_embedding")
|
||||||
|
op.drop_column("ml_settings", "centroid_similarity_threshold")
|
||||||
|
op.drop_column("ml_settings", "min_reference_images")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"min_reference_images", sa.Integer(), nullable=False,
|
||||||
|
server_default="5",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"centroid_similarity_threshold", sa.Float(), nullable=False,
|
||||||
|
server_default="0.55",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"tag_reference_embedding",
|
||||||
|
sa.Column("tag_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("embedding", sa.LargeBinary(), nullable=False),
|
||||||
|
sa.Column("reference_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("model_version", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(), nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["tag_id"], ["tag.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("tag_id"),
|
||||||
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""ML admin API: settings, backfill trigger, centroid recompute trigger."""
|
"""ML admin API: settings + backfill trigger."""
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
@@ -11,8 +11,6 @@ ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
|
|||||||
_EDITABLE = (
|
_EDITABLE = (
|
||||||
"suggestion_threshold_character",
|
"suggestion_threshold_character",
|
||||||
"suggestion_threshold_general",
|
"suggestion_threshold_general",
|
||||||
"centroid_similarity_threshold",
|
|
||||||
"min_reference_images",
|
|
||||||
"tagger_store_floor",
|
"tagger_store_floor",
|
||||||
"video_frame_interval_seconds",
|
"video_frame_interval_seconds",
|
||||||
"video_max_frames",
|
"video_max_frames",
|
||||||
@@ -41,8 +39,6 @@ async def get_settings():
|
|||||||
{
|
{
|
||||||
"suggestion_threshold_character": s.suggestion_threshold_character,
|
"suggestion_threshold_character": s.suggestion_threshold_character,
|
||||||
"suggestion_threshold_general": s.suggestion_threshold_general,
|
"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_store_floor": s.tagger_store_floor,
|
||||||
"video_frame_interval_seconds": s.video_frame_interval_seconds,
|
"video_frame_interval_seconds": s.video_frame_interval_seconds,
|
||||||
"video_max_frames": s.video_max_frames,
|
"video_max_frames": s.video_max_frames,
|
||||||
@@ -142,11 +138,3 @@ async def trigger_backfill():
|
|||||||
|
|
||||||
r = backfill.delay()
|
r = backfill.delay()
|
||||||
return jsonify({"celery_task_id": r.id}), 202
|
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
|
|
||||||
|
|||||||
@@ -304,12 +304,6 @@ async def merge_tag(source_id: int):
|
|||||||
from ..tasks.ml import apply_allowlist_tags
|
from ..tasks.ml import apply_allowlist_tags
|
||||||
|
|
||||||
apply_allowlist_tags.delay(tag_id=result.target_id)
|
apply_allowlist_tags.delay(tag_id=result.target_id)
|
||||||
# Tag merge invalidates the target's centroid (the merged-in source
|
|
||||||
# tag's images now contribute to it). Daily list_drifted catches it
|
|
||||||
# within 24h, but eager recompute closes the suggestion-quality dip
|
|
||||||
# in the meantime. Audit 2026-06-02.
|
|
||||||
from ..tasks.ml import recompute_centroid
|
|
||||||
recompute_centroid.delay(result.target_id)
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"target": {
|
"target": {
|
||||||
|
|||||||
@@ -101,10 +101,6 @@ def make_celery() -> Celery:
|
|||||||
"task": "backend.app.tasks.ml.backfill",
|
"task": "backend.app.tasks.ml.backfill",
|
||||||
"schedule": 86400.0,
|
"schedule": 86400.0,
|
||||||
},
|
},
|
||||||
"recompute-centroids-daily": {
|
|
||||||
"task": "backend.app.tasks.ml.recompute_centroids",
|
|
||||||
"schedule": 86400.0,
|
|
||||||
},
|
|
||||||
"apply-allowlist-sweep-daily": {
|
"apply-allowlist-sweep-daily": {
|
||||||
"task": "backend.app.tasks.ml.apply_allowlist_tags",
|
"task": "backend.app.tasks.ml.apply_allowlist_tags",
|
||||||
"schedule": 86400.0,
|
"schedule": 86400.0,
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ from .tag_allowlist import TagAllowlist
|
|||||||
from .tag_eval_run import TagEvalRun
|
from .tag_eval_run import TagEvalRun
|
||||||
from .tag_head import TagHead
|
from .tag_head import TagHead
|
||||||
from .tag_positive_confirmation import TagPositiveConfirmation
|
from .tag_positive_confirmation import TagPositiveConfirmation
|
||||||
from .tag_reference_embedding import TagReferenceEmbedding
|
|
||||||
from .tag_suggestion_rejection import TagSuggestionRejection
|
from .tag_suggestion_rejection import TagSuggestionRejection
|
||||||
from .task_run import TaskRun
|
from .task_run import TaskRun
|
||||||
|
|
||||||
@@ -83,7 +82,6 @@ __all__ = [
|
|||||||
"TagEvalRun",
|
"TagEvalRun",
|
||||||
"TagHead",
|
"TagHead",
|
||||||
"TagPositiveConfirmation",
|
"TagPositiveConfirmation",
|
||||||
"TagReferenceEmbedding",
|
|
||||||
"TagSuggestionRejection",
|
"TagSuggestionRejection",
|
||||||
"TaskRun",
|
"TaskRun",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -33,21 +33,14 @@ class MLSettings(Base):
|
|||||||
suggestion_threshold_general: Mapped[float] = mapped_column(
|
suggestion_threshold_general: Mapped[float] = mapped_column(
|
||||||
Float, nullable=False, default=0.70
|
Float, nullable=False, default=0.70
|
||||||
)
|
)
|
||||||
centroid_similarity_threshold: Mapped[float] = mapped_column(
|
|
||||||
Float, nullable=False, default=0.55
|
|
||||||
)
|
|
||||||
# Ingest floor: tagger predictions below this confidence are not stored
|
# Ingest floor: tagger predictions below this confidence are not stored
|
||||||
# (tagger.Tagger.infer). Default 0.70 — the suggestion path already
|
# (tagger.Tagger.infer). Default 0.70 — the suggestion path already filters
|
||||||
# filters at 0.70 and the centroid/learned path covers low-confidence
|
# there, so the sub-0.70 tail is redundant weight (it had bloated
|
||||||
# preferred tags, so the sub-0.70 tail is redundant weight (it had
|
# image_record's TOAST to ~100 GB; plan-task #764). Operator-tunable via
|
||||||
# bloated image_record's TOAST to ~100 GB; plan-task #764). Operator-
|
# Settings → ML; must stay ≤ the suggestion thresholds.
|
||||||
# tunable via Settings → ML; must stay ≤ the suggestion thresholds.
|
|
||||||
tagger_store_floor: Mapped[float] = mapped_column(
|
tagger_store_floor: Mapped[float] = mapped_column(
|
||||||
Float, nullable=False, default=0.70
|
Float, nullable=False, default=0.70
|
||||||
)
|
)
|
||||||
min_reference_images: Mapped[int] = mapped_column(
|
|
||||||
Integer, nullable=False, default=5
|
|
||||||
)
|
|
||||||
# Video tagging (#747). Sample one frame every N seconds (fixed CADENCE, not a
|
# Video tagging (#747). Sample one frame every N seconds (fixed CADENCE, not a
|
||||||
# fixed count) so a tag's frame-presence reflects real screen time regardless
|
# fixed count) so a tag's frame-presence reflects real screen time regardless
|
||||||
# of video length; cap the total so a long video can't explode into hundreds
|
# of video length; cap the total so a long video can't explode into hundreds
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
"""TagReferenceEmbedding — per-tag centroid (mean SigLIP embedding of members)."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pgvector.sqlalchemy import Vector
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class TagReferenceEmbedding(Base):
|
|
||||||
__tablename__ = "tag_reference_embedding"
|
|
||||||
|
|
||||||
tag_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
|
|
||||||
)
|
|
||||||
embedding: Mapped[list[float]] = mapped_column(Vector(1152), nullable=False)
|
|
||||||
reference_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
||||||
model_version: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
||||||
)
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
"""Tag centroids: the mean SigLIP embedding of a tag's member images.
|
|
||||||
|
|
||||||
Powers centroid-augmented suggestions (a tag whose centroid is close to an
|
|
||||||
image's embedding becomes a suggestion even if Camie didn't predict it).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from ...models import (
|
|
||||||
ImageRecord,
|
|
||||||
MLSettings,
|
|
||||||
Tag,
|
|
||||||
TagKind,
|
|
||||||
TagReferenceEmbedding,
|
|
||||||
)
|
|
||||||
from ...models.tag import image_tag
|
|
||||||
|
|
||||||
ELIGIBLE_KINDS = {
|
|
||||||
TagKind.character,
|
|
||||||
TagKind.fandom,
|
|
||||||
TagKind.general,
|
|
||||||
TagKind.series,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CentroidHit:
|
|
||||||
tag_id: int
|
|
||||||
similarity: float
|
|
||||||
|
|
||||||
|
|
||||||
class CentroidService:
|
|
||||||
def __init__(self, session: AsyncSession):
|
|
||||||
self.session = session
|
|
||||||
|
|
||||||
async def _min_reference_images(self) -> int:
|
|
||||||
return (
|
|
||||||
await self.session.execute(
|
|
||||||
select(MLSettings.min_reference_images).where(MLSettings.id == 1)
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
|
|
||||||
async def _model_version(self) -> str:
|
|
||||||
"""Audit 2026-06-02: SigLIP model-version stamp comes from the
|
|
||||||
DB row, not the env constant. tag_and_embed (tasks/ml.py:110)
|
|
||||||
already reads from MLSettings.embedder_model_version, so by
|
|
||||||
sourcing centroid stamps + drift checks from the same row, we
|
|
||||||
eliminate the silent-drift case the audit flagged. env
|
|
||||||
SIGLIP_MODEL_VERSION still drives which model embedder.py
|
|
||||||
loads at runtime; the version stamp is purely the operator-
|
|
||||||
controlled identifier."""
|
|
||||||
return (
|
|
||||||
await self.session.execute(
|
|
||||||
select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
|
|
||||||
async def recompute_for_tag(self, tag_id: int) -> bool:
|
|
||||||
"""Recompute one tag's centroid. Returns True if a centroid was
|
|
||||||
written, False if skipped (ineligible kind or too few members)."""
|
|
||||||
tag = await self.session.get(Tag, tag_id)
|
|
||||||
if tag is None or tag.kind not in ELIGIBLE_KINDS:
|
|
||||||
return False
|
|
||||||
|
|
||||||
min_refs = await self._min_reference_images()
|
|
||||||
|
|
||||||
stmt = (
|
|
||||||
select(ImageRecord.siglip_embedding)
|
|
||||||
.join(image_tag, image_tag.c.image_record_id == ImageRecord.id)
|
|
||||||
.where(image_tag.c.tag_id == tag_id)
|
|
||||||
.where(ImageRecord.siglip_embedding.is_not(None))
|
|
||||||
)
|
|
||||||
embeddings = [
|
|
||||||
np.array(e, dtype=np.float32)
|
|
||||||
for e in (await self.session.execute(stmt)).scalars().all()
|
|
||||||
]
|
|
||||||
if len(embeddings) < min_refs:
|
|
||||||
return False
|
|
||||||
|
|
||||||
centroid = np.mean(np.stack(embeddings), axis=0).astype(np.float32)
|
|
||||||
model_version = await self._model_version()
|
|
||||||
|
|
||||||
stmt = insert(TagReferenceEmbedding).values(
|
|
||||||
tag_id=tag_id,
|
|
||||||
embedding=centroid.tolist(),
|
|
||||||
reference_count=len(embeddings),
|
|
||||||
model_version=model_version,
|
|
||||||
)
|
|
||||||
stmt = stmt.on_conflict_do_update(
|
|
||||||
index_elements=["tag_id"],
|
|
||||||
set_={
|
|
||||||
"embedding": centroid.tolist(),
|
|
||||||
"reference_count": len(embeddings),
|
|
||||||
"model_version": model_version,
|
|
||||||
"updated_at": func.now(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await self.session.execute(stmt)
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def list_drifted(self) -> list[int]:
|
|
||||||
"""Tag ids whose centroid is stale: member count != reference_count,
|
|
||||||
OR no centroid row, OR centroid built on a different SigLIP version.
|
|
||||||
Only considers eligible-kind tags with embeddings present."""
|
|
||||||
current_model_version = await self._model_version()
|
|
||||||
member_counts = (
|
|
||||||
select(
|
|
||||||
image_tag.c.tag_id.label("tag_id"),
|
|
||||||
func.count(image_tag.c.image_record_id).label("members"),
|
|
||||||
)
|
|
||||||
.join(ImageRecord, ImageRecord.id == image_tag.c.image_record_id)
|
|
||||||
.where(ImageRecord.siglip_embedding.is_not(None))
|
|
||||||
.group_by(image_tag.c.tag_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
stmt = (
|
|
||||||
select(Tag.id)
|
|
||||||
.join(member_counts, member_counts.c.tag_id == Tag.id)
|
|
||||||
.outerjoin(
|
|
||||||
TagReferenceEmbedding,
|
|
||||||
TagReferenceEmbedding.tag_id == Tag.id,
|
|
||||||
)
|
|
||||||
.where(Tag.kind.in_(ELIGIBLE_KINDS))
|
|
||||||
.where(
|
|
||||||
(TagReferenceEmbedding.tag_id.is_(None))
|
|
||||||
| (
|
|
||||||
TagReferenceEmbedding.reference_count
|
|
||||||
!= member_counts.c.members
|
|
||||||
)
|
|
||||||
| (TagReferenceEmbedding.model_version != current_model_version)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return list((await self.session.execute(stmt)).scalars().all())
|
|
||||||
|
|
||||||
async def find_similar_tags(
|
|
||||||
self, image_id: int, limit: int = 20
|
|
||||||
) -> list[CentroidHit]:
|
|
||||||
"""Cosine similarity between an image's embedding and stored
|
|
||||||
centroids. Returns top-`limit` by similarity DESC. pgvector's
|
|
||||||
cosine_distance gives 1 - cosine_similarity."""
|
|
||||||
img = await self.session.get(ImageRecord, image_id)
|
|
||||||
if img is None or img.siglip_embedding is None:
|
|
||||||
return []
|
|
||||||
emb = img.siglip_embedding
|
|
||||||
distance = TagReferenceEmbedding.embedding.cosine_distance(emb)
|
|
||||||
stmt = (
|
|
||||||
select(
|
|
||||||
TagReferenceEmbedding.tag_id,
|
|
||||||
(1 - distance).label("similarity"),
|
|
||||||
)
|
|
||||||
.order_by(distance.asc())
|
|
||||||
.limit(limit)
|
|
||||||
)
|
|
||||||
rows = (await self.session.execute(stmt)).all()
|
|
||||||
return [
|
|
||||||
CentroidHit(tag_id=r.tag_id, similarity=float(r.similarity))
|
|
||||||
for r in rows
|
|
||||||
]
|
|
||||||
@@ -11,7 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from ..models import HeadMetric, Tag, TagHead, TagKind, image_tag
|
from ..models import HeadMetric, Tag, TagHead, TagKind, image_tag
|
||||||
from ..models.tag_allowlist import TagAllowlist
|
from ..models.tag_allowlist import TagAllowlist
|
||||||
from ..models.tag_reference_embedding import TagReferenceEmbedding
|
|
||||||
from .db_helpers import get_or_create
|
from .db_helpers import get_or_create
|
||||||
from .tag_query import fandom_join_alias, tag_columns
|
from .tag_query import fandom_join_alias, tag_columns
|
||||||
|
|
||||||
@@ -304,10 +303,10 @@ class TagService:
|
|||||||
|
|
||||||
async def _keep_as_alias(self, tag_id: int) -> bool:
|
async def _keep_as_alias(self, tag_id: int) -> bool:
|
||||||
"""A merged-away tag's old name must survive as an alias iff the ML
|
"""A merged-away tag's old name must survive as an alias iff the ML
|
||||||
pipeline has ever applied it OR could re-emit it (allowlisted / has
|
pipeline has ever applied it OR could re-emit it (allowlisted) —
|
||||||
a centroid) — otherwise the proactive apply_allowlist_tags worker
|
otherwise the proactive apply_allowlist_tags worker would silently
|
||||||
would silently regenerate it. Purely-manual, ML-unknown tags are
|
regenerate it. Purely-manual, ML-unknown tags are deleted outright (no
|
||||||
deleted outright (no DB bloat)."""
|
DB bloat)."""
|
||||||
is_machine = await self.session.scalar(
|
is_machine = await self.session.scalar(
|
||||||
select(
|
select(
|
||||||
exists().where(
|
exists().where(
|
||||||
@@ -325,14 +324,7 @@ class TagService:
|
|||||||
allowlisted = await self.session.scalar(
|
allowlisted = await self.session.scalar(
|
||||||
select(exists().where(TagAllowlist.tag_id == tag_id))
|
select(exists().where(TagAllowlist.tag_id == tag_id))
|
||||||
)
|
)
|
||||||
if allowlisted:
|
return bool(allowlisted)
|
||||||
return True
|
|
||||||
has_centroid = await self.session.scalar(
|
|
||||||
select(
|
|
||||||
exists().where(TagReferenceEmbedding.tag_id == tag_id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return bool(has_centroid)
|
|
||||||
|
|
||||||
async def rename(self, tag_id: int, new_name: str) -> Tag:
|
async def rename(self, tag_id: int, new_name: str) -> Tag:
|
||||||
"""Rename a tag. Raises TagMergeConflict if the new name collides
|
"""Rename a tag. Raises TagMergeConflict if the new name collides
|
||||||
@@ -573,7 +565,6 @@ class TagService:
|
|||||||
merged_count = await self._repoint_image_tags(source_id, target_id)
|
merged_count = await self._repoint_image_tags(source_id, target_id)
|
||||||
await self._repoint_rejections(source_id, target_id)
|
await self._repoint_rejections(source_id, target_id)
|
||||||
await self._repoint_allowlist(source_id, target_id)
|
await self._repoint_allowlist(source_id, target_id)
|
||||||
await self._repoint_embedding(source_id)
|
|
||||||
await self._repoint_aliases(source_id, target_id)
|
await self._repoint_aliases(source_id, target_id)
|
||||||
await self._repoint_fandom_children(
|
await self._repoint_fandom_children(
|
||||||
source_id, target_id, source_kind
|
source_id, target_id, source_kind
|
||||||
@@ -655,13 +646,6 @@ class TagService:
|
|||||||
.values(tag_id=tgt)
|
.values(tag_id=tgt)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _repoint_embedding(self, src: int) -> None:
|
|
||||||
await self.session.execute(
|
|
||||||
text(
|
|
||||||
"DELETE FROM tag_reference_embedding WHERE tag_id = :src"
|
|
||||||
),
|
|
||||||
{"src": src},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _repoint_aliases(self, src: int, tgt: int) -> None:
|
async def _repoint_aliases(self, src: int, tgt: int) -> None:
|
||||||
from ..models.tag_alias import TagAlias
|
from ..models.tag_alias import TagAlias
|
||||||
|
|||||||
+5
-58
@@ -1,9 +1,9 @@
|
|||||||
"""ML Celery tasks: per-image inference, backfill discovery, centroid
|
"""ML Celery tasks: per-image inference, backfill discovery, head training,
|
||||||
recompute, allowlist auto-apply, model self-heal.
|
allowlist auto-apply, model self-heal.
|
||||||
|
|
||||||
All run on the ml-worker (queue 'ml') except recompute_centroids and
|
All run on the ml-worker (queue 'ml') except apply_allowlist_tags sweeps which
|
||||||
apply_allowlist_tags sweeps which are 'maintenance' lane. Sync sessions
|
are 'maintenance' lane. Sync sessions (Celery workers are sync processes), same
|
||||||
(Celery workers are sync processes), same pattern as FC-2a tasks.
|
pattern as FC-2a tasks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -487,59 +487,6 @@ def _confidence_for_tag(session, tag, preds: dict) -> float | None:
|
|||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="backend.app.tasks.ml.recompute_centroid", bind=True)
|
|
||||||
def recompute_centroid(self, tag_id: int) -> bool:
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from ..services.ml.centroids import CentroidService
|
|
||||||
from ._async_session import async_session_factory
|
|
||||||
|
|
||||||
async def _run() -> bool:
|
|
||||||
# Per-task NullPool engine bound to THIS asyncio.run loop — the shared
|
|
||||||
# process-wide engine reuses connections across loops and raises
|
|
||||||
# "Future attached to a different loop" on every call after the first.
|
|
||||||
async_factory, async_engine = async_session_factory()
|
|
||||||
try:
|
|
||||||
async with async_factory() as session:
|
|
||||||
svc = CentroidService(session)
|
|
||||||
result = await svc.recompute_for_tag(tag_id)
|
|
||||||
await session.commit()
|
|
||||||
return result
|
|
||||||
finally:
|
|
||||||
await async_engine.dispose()
|
|
||||||
|
|
||||||
return asyncio.run(_run())
|
|
||||||
|
|
||||||
|
|
||||||
@celery.task(
|
|
||||||
name="backend.app.tasks.ml.recompute_centroids",
|
|
||||||
bind=True,
|
|
||||||
# Audit 2026-06-02 — drifted-centroid rebuild over potentially
|
|
||||||
# hundreds of tags.
|
|
||||||
soft_time_limit=1800, time_limit=2100,
|
|
||||||
)
|
|
||||||
def recompute_centroids(self) -> int:
|
|
||||||
"""Daily: find drifted centroids, enqueue recompute_centroid for each."""
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from ..services.ml.centroids import CentroidService
|
|
||||||
from ._async_session import async_session_factory
|
|
||||||
|
|
||||||
async def _list() -> list[int]:
|
|
||||||
# Per-task NullPool engine bound to this loop (see recompute_centroid).
|
|
||||||
async_factory, async_engine = async_session_factory()
|
|
||||||
try:
|
|
||||||
async with async_factory() as session:
|
|
||||||
return await CentroidService(session).list_drifted()
|
|
||||||
finally:
|
|
||||||
await async_engine.dispose()
|
|
||||||
|
|
||||||
drifted = asyncio.run(_list())
|
|
||||||
for tid in drifted:
|
|
||||||
recompute_centroid.delay(tid)
|
|
||||||
return len(drifted)
|
|
||||||
|
|
||||||
|
|
||||||
@celery.task(
|
@celery.task(
|
||||||
name="backend.app.tasks.ml.tag_eval_run",
|
name="backend.app.tasks.ml.tag_eval_run",
|
||||||
bind=True,
|
bind=True,
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
<template>
|
|
||||||
<MaintenanceTile
|
|
||||||
icon="mdi-vector-triangle"
|
|
||||||
title="Tag centroids"
|
|
||||||
blurb="Rebuild SigLIP centroids for similarity suggestions."
|
|
||||||
:open="busy"
|
|
||||||
>
|
|
||||||
<p class="text-body-2 mb-3">
|
|
||||||
Rebuild the per-tag SigLIP centroids that power similarity-based
|
|
||||||
suggestions. Runs nightly automatically; trigger manually after a
|
|
||||||
large tagging session.
|
|
||||||
</p>
|
|
||||||
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
|
|
||||||
<v-icon start>mdi-vector-triangle</v-icon> Recompute centroids
|
|
||||||
</v-btn>
|
|
||||||
<span v-if="done" class="ml-3 text-caption">Enqueued.</span>
|
|
||||||
<QueueStatusBar queue="ml" queue-label="ML" />
|
|
||||||
</MaintenanceTile>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { toast } from '../../utils/toast.js'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useMLStore } from '../../stores/ml.js'
|
|
||||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
|
||||||
import QueueStatusBar from './QueueStatusBar.vue'
|
|
||||||
const store = useMLStore()
|
|
||||||
const busy = ref(false)
|
|
||||||
const done = ref(false)
|
|
||||||
async function run() {
|
|
||||||
busy.value = true
|
|
||||||
try { await store.triggerRecomputeCentroids(); done.value = true }
|
|
||||||
catch (e) { toast({ text: e.message, type: 'error' }) }
|
|
||||||
finally { busy.value = false }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -28,8 +28,7 @@
|
|||||||
<div class="text-caption fc-muted mt-1">
|
<div class="text-caption fc-muted mt-1">
|
||||||
Tagger predictions below this confidence aren't stored — raising it
|
Tagger predictions below this confidence aren't stored — raising it
|
||||||
keeps the image library lean. Suggestions can't be shown below the
|
keeps the image library lean. Suggestions can't be shown below the
|
||||||
floor; lower-confidence tags you actually want still surface through
|
floor.
|
||||||
the learned centroid path.
|
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
@@ -84,8 +83,7 @@ const store = useMLStore()
|
|||||||
// tagger store floor (nothing below the floor is stored to surface).
|
// tagger store floor (nothing below the floor is stored to surface).
|
||||||
const fields = [
|
const fields = [
|
||||||
{ key: 'suggestion_threshold_character', label: 'Character', floorMin: true },
|
{ key: 'suggestion_threshold_character', label: 'Character', floorMin: true },
|
||||||
{ key: 'suggestion_threshold_general', label: 'General', floorMin: true },
|
{ key: 'suggestion_threshold_general', label: 'General', floorMin: true }
|
||||||
{ key: 'centroid_similarity_threshold', label: 'Centroid similarity' }
|
|
||||||
]
|
]
|
||||||
const local = reactive({})
|
const local = reactive({})
|
||||||
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fc-maint">
|
<div class="fc-maint">
|
||||||
<p class="fc-muted text-body-2 mb-5">
|
<p class="fc-muted text-body-2 mb-5">
|
||||||
One-off backfills, tagging config and storage tools. The ML backfill and
|
One-off backfills, tagging config and storage tools. The ML backfill runs
|
||||||
centroid recompute also run nightly; the allowlist auto-applies accepted
|
nightly; the allowlist auto-applies accepted tags. Click a tile to open it.
|
||||||
tags. Click a tile to open it.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section class="fc-section">
|
<section class="fc-section">
|
||||||
@@ -11,7 +10,6 @@
|
|||||||
<p class="fc-section__hint">Re-run tagging, thumbnails, extraction and DB upkeep.</p>
|
<p class="fc-section__hint">Re-run tagging, thumbnails, extraction and DB upkeep.</p>
|
||||||
<div class="fc-tile-grid">
|
<div class="fc-tile-grid">
|
||||||
<MLBackfillCard />
|
<MLBackfillCard />
|
||||||
<CentroidRecomputeCard />
|
|
||||||
<ThumbnailBackfillCard />
|
<ThumbnailBackfillCard />
|
||||||
<ArchiveReextractCard />
|
<ArchiveReextractCard />
|
||||||
<MissingFileRepairCard />
|
<MissingFileRepairCard />
|
||||||
@@ -48,7 +46,6 @@
|
|||||||
import { onMounted, onUnmounted } from 'vue'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
import MLBackfillCard from './MLBackfillCard.vue'
|
import MLBackfillCard from './MLBackfillCard.vue'
|
||||||
import CentroidRecomputeCard from './CentroidRecomputeCard.vue'
|
|
||||||
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
|
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
|
||||||
import ArchiveReextractCard from './ArchiveReextractCard.vue'
|
import ArchiveReextractCard from './ArchiveReextractCard.vue'
|
||||||
import MissingFileRepairCard from './MissingFileRepairCard.vue'
|
import MissingFileRepairCard from './MissingFileRepairCard.vue'
|
||||||
|
|||||||
@@ -22,12 +22,8 @@ export const useMLStore = defineStore('ml', () => {
|
|||||||
await api.post('/api/ml/backfill')
|
await api.post('/api/ml/backfill')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function triggerRecomputeCentroids() {
|
|
||||||
await api.post('/api/ml/recompute-centroids')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
settings, loading, error,
|
settings, loading, error,
|
||||||
loadSettings, patchSettings, triggerBackfill, triggerRecomputeCentroids
|
loadSettings, patchSettings, triggerBackfill
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -107,11 +107,9 @@ async def test_video_min_tag_frames_above_max_rejected(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_backfill_and_recompute_trigger(client):
|
async def test_backfill_trigger(client):
|
||||||
r1 = await client.post("/api/ml/backfill")
|
r1 = await client.post("/api/ml/backfill")
|
||||||
assert r1.status_code == 202
|
assert r1.status_code == 202
|
||||||
r2 = await client.post("/api/ml/recompute-centroids")
|
|
||||||
assert r2.status_code == 202
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from backend.app.models import (
|
|||||||
MLSettings,
|
MLSettings,
|
||||||
TagAlias,
|
TagAlias,
|
||||||
TagAllowlist,
|
TagAllowlist,
|
||||||
TagReferenceEmbedding,
|
|
||||||
TagSuggestionRejection,
|
TagSuggestionRejection,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,7 +15,6 @@ def test_new_tables_registered():
|
|||||||
"tag_allowlist",
|
"tag_allowlist",
|
||||||
"tag_suggestion_rejection",
|
"tag_suggestion_rejection",
|
||||||
"tag_alias",
|
"tag_alias",
|
||||||
"tag_reference_embedding",
|
|
||||||
"ml_settings",
|
"ml_settings",
|
||||||
}
|
}
|
||||||
assert expected.issubset(Base.metadata.tables.keys())
|
assert expected.issubset(Base.metadata.tables.keys())
|
||||||
@@ -42,12 +40,6 @@ def test_ml_settings_singleton_constraint():
|
|||||||
assert "ck_ml_settings_singleton" in names
|
assert "ck_ml_settings_singleton" in names
|
||||||
|
|
||||||
|
|
||||||
def test_tag_reference_embedding_has_vector():
|
|
||||||
cols = {c.name for c in TagReferenceEmbedding.__table__.columns}
|
|
||||||
assert "embedding" in cols
|
|
||||||
assert "reference_count" in cols
|
|
||||||
|
|
||||||
|
|
||||||
def test_tag_allowlist_confidence_check():
|
def test_tag_allowlist_confidence_check():
|
||||||
names = {c.name for c in TagAllowlist.__table__.constraints}
|
names = {c.name for c in TagAllowlist.__table__.constraints}
|
||||||
assert "ck_tag_allowlist_confidence_range" in names
|
assert "ck_tag_allowlist_confidence_range" in names
|
||||||
|
|||||||
@@ -8,12 +8,6 @@ def test_artist_not_surfaced():
|
|||||||
assert "artist" not in SURFACED_CATEGORIES
|
assert "artist" not in SURFACED_CATEGORIES
|
||||||
|
|
||||||
|
|
||||||
def test_artist_not_centroid_eligible():
|
|
||||||
from backend.app.models import TagKind
|
|
||||||
from backend.app.services.ml.centroids import ELIGIBLE_KINDS
|
|
||||||
assert TagKind.artist not in ELIGIBLE_KINDS
|
|
||||||
|
|
||||||
|
|
||||||
def test_artist_not_head_eligible():
|
def test_artist_not_head_eligible():
|
||||||
# Tagging-v2: suggestions come from heads, and heads are only trained for
|
# Tagging-v2: suggestions come from heads, and heads are only trained for
|
||||||
# general/character concepts — so 'artist' (and any other kind) can't surface.
|
# general/character concepts — so 'artist' (and any other kind) can't surface.
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from backend.app.models import ImageRecord, TagKind
|
|
||||||
from backend.app.models.tag import image_tag
|
|
||||||
from backend.app.services.ml.centroids import CentroidService
|
|
||||||
from backend.app.services.tag_service import TagService
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
|
||||||
|
|
||||||
|
|
||||||
def _img(sha: str, embedding: list[float] | None) -> ImageRecord:
|
|
||||||
return ImageRecord(
|
|
||||||
path=f"/images/{sha}.jpg",
|
|
||||||
sha256=sha,
|
|
||||||
size_bytes=1,
|
|
||||||
mime="image/jpeg",
|
|
||||||
width=1,
|
|
||||||
height=1,
|
|
||||||
origin="imported_filesystem",
|
|
||||||
integrity_status="unknown",
|
|
||||||
siglip_embedding=embedding,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _attach(db, image_id: int, tag_id: int):
|
|
||||||
await db.execute(
|
|
||||||
image_tag.insert().values(
|
|
||||||
image_record_id=image_id, tag_id=tag_id, source="manual"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_recompute_skips_too_few_members(db):
|
|
||||||
tags = TagService(db)
|
|
||||||
tag = await tags.find_or_create("Lonely", TagKind.character)
|
|
||||||
img = _img("a" * 64, [0.1] * 1152)
|
|
||||||
db.add(img)
|
|
||||||
await db.flush()
|
|
||||||
await _attach(db, img.id, tag.id)
|
|
||||||
|
|
||||||
svc = CentroidService(db)
|
|
||||||
assert await svc.recompute_for_tag(tag.id) is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_recompute_writes_centroid(db):
|
|
||||||
tags = TagService(db)
|
|
||||||
tag = await tags.find_or_create("Popular", TagKind.character)
|
|
||||||
for i in range(5):
|
|
||||||
img = _img(f"{i:064d}", [float(i)] * 1152)
|
|
||||||
db.add(img)
|
|
||||||
await db.flush()
|
|
||||||
await _attach(db, img.id, tag.id)
|
|
||||||
|
|
||||||
svc = CentroidService(db)
|
|
||||||
assert await svc.recompute_for_tag(tag.id) is True
|
|
||||||
|
|
||||||
from backend.app.models import TagReferenceEmbedding
|
|
||||||
cen = await db.get(TagReferenceEmbedding, tag.id)
|
|
||||||
assert cen is not None
|
|
||||||
assert cen.reference_count == 5
|
|
||||||
assert abs(np.array(cen.embedding)[0] - 2.0) < 1e-4
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_recompute_skips_ineligible_kind(db):
|
|
||||||
tags = TagService(db)
|
|
||||||
tag = await tags.find_or_create("somearchive", TagKind.archive)
|
|
||||||
for i in range(5):
|
|
||||||
img = _img(f"arch{i:060d}", [1.0] * 1152)
|
|
||||||
db.add(img)
|
|
||||||
await db.flush()
|
|
||||||
await _attach(db, img.id, tag.id)
|
|
||||||
svc = CentroidService(db)
|
|
||||||
assert await svc.recompute_for_tag(tag.id) is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_drifted_includes_uncomputed(db):
|
|
||||||
tags = TagService(db)
|
|
||||||
tag = await tags.find_or_create("Drifty", TagKind.character)
|
|
||||||
for i in range(5):
|
|
||||||
img = _img(f"d{i:063d}", [0.5] * 1152)
|
|
||||||
db.add(img)
|
|
||||||
await db.flush()
|
|
||||||
await _attach(db, img.id, tag.id)
|
|
||||||
svc = CentroidService(db)
|
|
||||||
drifted = await svc.list_drifted()
|
|
||||||
assert tag.id in drifted
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_find_similar_tags(db):
|
|
||||||
tags = TagService(db)
|
|
||||||
tag = await tags.find_or_create("SimTag", TagKind.character)
|
|
||||||
for i in range(5):
|
|
||||||
img = _img(f"s{i:063d}", [1.0] * 1152)
|
|
||||||
db.add(img)
|
|
||||||
await db.flush()
|
|
||||||
await _attach(db, img.id, tag.id)
|
|
||||||
svc = CentroidService(db)
|
|
||||||
await svc.recompute_for_tag(tag.id)
|
|
||||||
|
|
||||||
query_img = _img("q" * 64, [1.0] * 1152)
|
|
||||||
db.add(query_img)
|
|
||||||
await db.flush()
|
|
||||||
hits = await svc.find_similar_tags(query_img.id, limit=10)
|
|
||||||
assert any(h.tag_id == tag.id for h in hits)
|
|
||||||
sim = next(h.similarity for h in hits if h.tag_id == tag.id)
|
|
||||||
assert sim > 0.99
|
|
||||||
@@ -279,34 +279,6 @@ async def test_merge_allowlist_source_only_moves_to_target(db):
|
|||||||
assert rows[0].min_confidence == 0.42
|
assert rows[0].min_confidence == 0.42
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_merge_deletes_source_embedding(db):
|
|
||||||
from backend.app.models.tag_reference_embedding import (
|
|
||||||
TagReferenceEmbedding,
|
|
||||||
)
|
|
||||||
|
|
||||||
svc = TagService(db)
|
|
||||||
a = await svc.find_or_create("SrcEmb", TagKind.general)
|
|
||||||
b = await svc.find_or_create("TgtEmb", TagKind.general)
|
|
||||||
db.add(
|
|
||||||
TagReferenceEmbedding(
|
|
||||||
tag_id=a.id,
|
|
||||||
embedding=[0.0] * 1152,
|
|
||||||
reference_count=1,
|
|
||||||
model_version="v",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.flush()
|
|
||||||
await svc.merge(a.id, b.id)
|
|
||||||
db.expire_all() # merge() uses Core DML; drop stale identity-map state
|
|
||||||
remaining = await db.scalar(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(TagReferenceEmbedding)
|
|
||||||
.where(TagReferenceEmbedding.tag_id == a.id)
|
|
||||||
)
|
|
||||||
assert remaining == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_merge_repoints_existing_aliases(db):
|
async def test_merge_repoints_existing_aliases(db):
|
||||||
from backend.app.models.tag_alias import TagAlias
|
from backend.app.models.tag_alias import TagAlias
|
||||||
|
|||||||
Reference in New Issue
Block a user