refactor(ml): retire the Camie tagger + allowlist bulk-apply (#1189)
CI / lint (push) Failing after 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m27s

Heads + CCIP are the tag source and head auto-apply is the earned propagation.
The Camie tagger ran only to feed the allowlist bulk-apply (its ImagePrediction
rows had no other consumer), and the allowlist was a SECOND, un-earned auto-apply
path firing in parallel with heads on every accept — exactly the un-earned spray
the v2 pivot replaced. Retire both.

Behavior change: accepting a suggestion now applies the tag to THAT image only
(source='ml_accepted', a head-training positive) — it no longer allowlists +
fans the tag across the library via Camie. Propagation is heads' earned
auto-apply. (Loses instant cold-start propagation for booru-vocab tags; that was
un-earned and bypassed the precision gate.)

- tag_and_embed is now EMBED-ONLY (no Camie load/infer, no ImagePrediction
  writes); backfill enqueues it for images with no embedding.
- Removed: services/ml/tagger.py, apply_allowlist_tags + helpers + daily beat +
  every enqueue caller (accept/alias/merge/per-image), api/allowlist.py +
  blueprint, ImagePrediction + TagAllowlist models/tables (migration 0067),
  AllowlistTable.vue + allowlist store, the accept coverage-projection payload.
- AllowlistService gutted to accept/dismiss/undismiss/reject (the rejection store
  the rail still needs); accept returns nothing, API returns {accepted, tag_id}.
- tag merge no longer repoints/triggers the allowlist; _keep_as_alias now keys on
  ML-applied image_tag sources (incl. head_auto) instead of the allowlist.
- UI: MLBackfillCard relabelled to embedding-only; accept toast simplified;
  MaintenancePanel drops the allowlist tile.

Left for a follow-up hygiene pass (now-inert, harmless): the dead settings
columns (tagger_store_floor, tagger_model_version, suggestion_threshold_*,
video_min_tag_frames), image_record.tagger_model_version, MLThresholdSliders
trim, and the Camie model download in download_models.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
2026-06-30 13:04:31 -04:00
parent 3d77a38a25
commit 485387ff0b
31 changed files with 159 additions and 1710 deletions
-4
View File
@@ -13,7 +13,6 @@ from .head_auto_apply_run import HeadAutoApplyRun
from .head_metric import HeadMetric
from .head_metrics_snapshot import HeadMetricsSnapshot
from .head_training_run import HeadTrainingRun
from .image_prediction import ImagePrediction
from .image_provenance import ImageProvenance
from .image_record import ImageRecord
from .image_region import ImageRegion
@@ -34,7 +33,6 @@ from .subscribestar_failed_media import SubscribeStarFailedMedia
from .subscribestar_seen_media import SubscribeStarSeenMedia
from .tag import Tag, TagKind, image_tag
from .tag_alias import TagAlias
from .tag_allowlist import TagAllowlist
from .tag_eval_run import TagEvalRun
from .tag_head import TagHead
from .tag_positive_confirmation import TagPositiveConfirmation
@@ -59,7 +57,6 @@ __all__ = [
"SeriesPage",
"SeriesSuggestion",
"ImageRecord",
"ImagePrediction",
"ImageProvenance",
"ImageRegion",
"Tag",
@@ -78,7 +75,6 @@ __all__ = [
"HeadMetricsSnapshot",
"HeadTrainingRun",
"TagAlias",
"TagAllowlist",
"TagEvalRun",
"TagHead",
"TagPositiveConfirmation",
-37
View File
@@ -1,37 +0,0 @@
"""ImagePrediction — one row per (image, tagger vocab prediction).
Replaces the image_record.tagger_predictions JSON blob (#768). Storing the
raw Camie/booru vocab name (not a tag_id) preserves the suggestion read
path's semantics: raw_name → canonical Tag resolution happens at read time
via the alias map, and accepting a prediction can CREATE the Tag. The store
floor (ml_settings.tagger_store_floor) is applied at WRITE time, so only
predictions >= the floor land here.
"""
from sqlalchemy import Float, ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class ImagePrediction(Base):
__tablename__ = "image_prediction"
__table_args__ = (
UniqueConstraint(
"image_record_id", "raw_name", name="image_raw_name",
),
# Per-image read (suggestion build) and the "images with tag X above
# Y" query the JSON blob never allowed.
Index("ix_image_prediction_image", "image_record_id"),
Index("ix_image_prediction_name_score", "raw_name", "score"),
)
id: Mapped[int] = mapped_column(primary_key=True)
image_record_id: Mapped[int] = mapped_column(
ForeignKey("image_record.id", ondelete="CASCADE"), nullable=False,
)
# The raw tagger vocab key (booru form) — NOT a tag_id. Resolved to a
# canonical Tag at read time, exactly as the old JSON keys were.
raw_name: Mapped[str] = mapped_column(String(255), nullable=False)
category: Mapped[str] = mapped_column(String(64), nullable=False)
score: Mapped[float] = mapped_column(Float, nullable=False)
-32
View File
@@ -1,32 +0,0 @@
"""TagAllowlist — tags the operator opted-in to auto-apply via maintenance."""
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, Float, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class TagAllowlist(Base):
__tablename__ = "tag_allowlist"
# Bare name — Base.metadata's naming convention prepends ck_<table>_,
# producing the final ck_tag_allowlist_confidence_range (matches migration 0003).
__table_args__ = (
CheckConstraint(
"min_confidence > 0 AND min_confidence <= 1",
name="confidence_range",
),
)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
)
# Default auto-apply threshold for a newly-accepted tag. 0.90 (lowered from
# 0.95 on operator evidence 2026-06-07: 0.95 was too strict and skipped
# confident-enough applications). Per-tag value is still tunable in the
# allowlist table; existing rows keep whatever they were stored with.
min_confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.90)
added_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)