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
@@ -0,0 +1,66 @@
"""retire the Camie tagger + allowlist bulk-apply (#1189)
The v2 pivot made heads + CCIP the tag source and head auto-apply the earned
propagation. The Camie tagger ran only to feed the allowlist bulk-apply (its
predictions had no other consumer), and the allowlist was a second, un-earned
auto-apply path parallel to heads. Both are retired — drop their storage.
(image_prediction = Camie's per-image predictions; tag_allowlist = the bulk-
apply allowlist. Nothing references INTO these tables, so the drop is clean.)
Revision ID: 0067
Revises: 0066
Create Date: 2026-06-30
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0067"
down_revision: Union[str, None] = "0066"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_table("image_prediction")
op.drop_table("tag_allowlist")
def downgrade() -> None:
op.create_table(
"tag_allowlist",
sa.Column("tag_id", sa.Integer(), nullable=False),
sa.Column(
"min_confidence", sa.Float(), nullable=False, server_default="0.9"
),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
sa.ForeignKeyConstraint(["tag_id"], ["tag.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("tag_id"),
sa.CheckConstraint(
"min_confidence >= 0 AND min_confidence <= 1",
name="ck_tag_allowlist_confidence_range",
),
)
op.create_table(
"image_prediction",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("image_record_id", sa.Integer(), nullable=False),
sa.Column("raw_name", sa.String(length=255), nullable=False),
sa.Column("category", sa.String(length=32), nullable=False),
sa.Column("score", sa.Float(), nullable=False),
sa.ForeignKeyConstraint(
["image_record_id"], ["image_record.id"], ondelete="CASCADE"
),
)
op.create_index(
"ix_image_prediction_image", "image_prediction", ["image_record_id"]
)
op.create_index(
"ix_image_prediction_name_score", "image_prediction",
["raw_name", "score"],
)
-2
View File
@@ -16,7 +16,6 @@ api_bp.add_url_rule("/health", view_func=health.get_health, methods=["GET"])
def all_blueprints() -> list[Blueprint]: def all_blueprints() -> list[Blueprint]:
from .admin import admin_bp from .admin import admin_bp
from .aliases import aliases_bp from .aliases import aliases_bp
from .allowlist import allowlist_bp
from .artist import artist_bp from .artist import artist_bp
from .artists import artists_bp from .artists import artists_bp
from .attachments import attachments_bp from .attachments import attachments_bp
@@ -58,7 +57,6 @@ def all_blueprints() -> list[Blueprint]:
cleanup_bp, cleanup_bp,
import_admin_bp, import_admin_bp,
suggestions_bp, suggestions_bp,
allowlist_bp,
aliases_bp, aliases_bp,
tag_eval_bp, tag_eval_bp,
heads_bp, heads_bp,
-84
View File
@@ -1,84 +0,0 @@
"""Allowlist API: list, adjust threshold, remove."""
from quart import Blueprint, jsonify, request
from ..extensions import get_session
from ..models import TagAllowlist
from ..services.ml.allowlist import AllowlistService
allowlist_bp = Blueprint("allowlist", __name__, url_prefix="/api")
@allowlist_bp.route("/allowlist", methods=["GET"])
async def list_allowlist():
async with get_session() as session:
rows = await AllowlistService(session).list_all()
return jsonify(
[
{
"tag_id": r.tag_id,
"tag_name": r.tag_name,
"tag_kind": r.tag_kind,
"min_confidence": r.min_confidence,
"applied_count": r.applied_count,
"coverage_count": r.coverage_count,
}
for r in rows
]
)
@allowlist_bp.route("/tags/<int:tag_id>/allowlist/coverage", methods=["GET"])
async def coverage(tag_id: int):
"""Live "at threshold T, a sweep would cover ~N images" projection for the
allowlist tuning dashboard. Defaults to the tag's stored threshold."""
raw = request.args.get("threshold")
async with get_session() as session:
svc = AllowlistService(session)
if raw is not None:
try:
threshold = float(raw)
except ValueError:
return jsonify({"error": "threshold must be a float"}), 400
if not (0 < threshold <= 1):
return jsonify({"error": "threshold must be in (0, 1]"}), 400
else:
row = await session.get(TagAllowlist, tag_id)
if row is None:
return jsonify({"error": "not on allowlist"}), 404
threshold = row.min_confidence
count = await svc.coverage(tag_id, threshold)
return jsonify({"count": count, "threshold": threshold})
@allowlist_bp.route("/tags/<int:tag_id>/allowlist", methods=["GET"])
async def get_one(tag_id: int):
async with get_session() as session:
row = await session.get(TagAllowlist, tag_id)
if row is None:
return jsonify({"error": "not on allowlist"}), 404
return jsonify(
{"min_confidence": row.min_confidence, "added_at": row.added_at.isoformat()}
)
@allowlist_bp.route("/tags/<int:tag_id>/allowlist", methods=["PATCH"])
async def patch_threshold(tag_id: int):
body = await request.get_json()
if not body or "min_confidence" not in body:
return jsonify({"error": "min_confidence required"}), 400
mc = float(body["min_confidence"])
if not (0 < mc <= 1):
return jsonify({"error": "min_confidence must be in (0, 1]"}), 400
async with get_session() as session:
await AllowlistService(session).update_threshold(tag_id, mc)
await session.commit()
return "", 204
@allowlist_bp.route("/tags/<int:tag_id>/allowlist", methods=["DELETE"])
async def remove(tag_id: int):
async with get_session() as session:
await AllowlistService(session).remove(tag_id)
await session.commit()
return "", 204
+4 -37
View File
@@ -3,31 +3,12 @@
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from ..extensions import get_session from ..extensions import get_session
from ..models import Tag, TagAllowlist
from ..services.ml.allowlist import AllowlistService from ..services.ml.allowlist import AllowlistService
from ..services.ml.suggestions import SuggestionService from ..services.ml.suggestions import SuggestionService
suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api") suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
async def _accept_payload(session, svc, newly_added: bool, tag_id: int) -> dict:
"""Shape the accept/alias response. When accepting newly allowlists a tag,
include the coverage PROJECTION (at the tag's threshold) so the UI can show
a non-blocking "auto-applying to ~N images" toast — the actual apply runs
async via apply_allowlist_tags, so this is an estimate, not a post-hoc
count (#7)."""
payload = {"allowlisted": newly_added}
if newly_added:
tag = await session.get(Tag, tag_id)
row = await session.get(TagAllowlist, tag_id)
payload["tag_id"] = tag_id
payload["tag_name"] = tag.name if tag is not None else None
payload["projected_count"] = await svc.coverage(
tag_id, row.min_confidence if row is not None else 0.90,
)
return payload
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"]) @suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
async def get_suggestions(image_id: int): async def get_suggestions(image_id: int):
# ?min=<float> overrides the configured per-category thresholds so the typed # ?min=<float> overrides the configured per-category thresholds so the typed
@@ -83,15 +64,9 @@ async def accept_suggestion(image_id: int):
return jsonify({"error": "tag_id required"}), 400 return jsonify({"error": "tag_id required"}), 400
tag_id = body["tag_id"] tag_id = body["tag_id"]
async with get_session() as session: async with get_session() as session:
svc = AllowlistService(session) await AllowlistService(session).accept(image_id, tag_id)
newly_added = await svc.accept(image_id, tag_id)
payload = await _accept_payload(session, svc, newly_added, tag_id)
await session.commit() await session.commit()
if newly_added: return jsonify({"accepted": True, "tag_id": tag_id})
from ..tasks.ml import apply_allowlist_tags
apply_allowlist_tags.delay(tag_id=tag_id)
return jsonify(payload)
@suggestions_bp.route( @suggestions_bp.route(
@@ -104,22 +79,14 @@ async def alias_suggestion(image_id: int):
return jsonify({"error": f"required: {sorted(required)}"}), 400 return jsonify({"error": f"required: {sorted(required)}"}), 400
canonical_tag_id = body["canonical_tag_id"] canonical_tag_id = body["canonical_tag_id"]
async with get_session() as session: async with get_session() as session:
svc = AllowlistService(session) await AllowlistService(session).add_alias_and_accept(
newly_added = await svc.add_alias_and_accept(
image_id, image_id,
body["alias_string"], body["alias_string"],
body["alias_category"], body["alias_category"],
canonical_tag_id, canonical_tag_id,
) )
payload = await _accept_payload(
session, svc, newly_added, canonical_tag_id,
)
await session.commit() await session.commit()
if newly_added: return jsonify({"accepted": True, "tag_id": canonical_tag_id})
from ..tasks.ml import apply_allowlist_tags
apply_allowlist_tags.delay(tag_id=canonical_tag_id)
return jsonify(payload)
@suggestions_bp.route( @suggestions_bp.route(
+1 -9
View File
@@ -1,13 +1,12 @@
"""Tags API: autocomplete, create, list/add/remove for an image.""" """Tags API: autocomplete, create, list/add/remove for an image."""
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from sqlalchemy import exists, select from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from ..extensions import get_session from ..extensions import get_session
from ..models import Tag, TagKind, TagPositiveConfirmation from ..models import Tag, TagKind, TagPositiveConfirmation
from ..models.tag_allowlist import TagAllowlist
from ..services.bulk_tag_service import BulkTagService from ..services.bulk_tag_service import BulkTagService
from ..services.ml.aliases import AliasService from ..services.ml.aliases import AliasService
from ..services.series_match_service import SeriesMatchService from ..services.series_match_service import SeriesMatchService
@@ -297,13 +296,6 @@ async def merge_tag(source_id: int):
status = 404 if "not found" in msg else 400 status = 404 if "not found" in msg else 400
return jsonify({"error": msg}), status return jsonify({"error": msg}), status
await session.commit() await session.commit()
target_allowlisted = await session.scalar(
select(exists().where(TagAllowlist.tag_id == result.target_id))
)
if target_allowlisted:
from ..tasks.ml import apply_allowlist_tags
apply_allowlist_tags.delay(tag_id=result.target_id)
return jsonify( return jsonify(
{ {
"target": { "target": {
-4
View File
@@ -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,
}, },
"apply-allowlist-sweep-daily": {
"task": "backend.app.tasks.ml.apply_allowlist_tags",
"schedule": 86400.0,
},
"train-heads-nightly": { "train-heads-nightly": {
"task": "backend.app.tasks.ml.scheduled_train_heads", "task": "backend.app.tasks.ml.scheduled_train_heads",
"schedule": 86400.0, # passive cadence; manual retrain stays available "schedule": 86400.0, # passive cadence; manual retrain stays available
-4
View File
@@ -13,7 +13,6 @@ from .head_auto_apply_run import HeadAutoApplyRun
from .head_metric import HeadMetric from .head_metric import HeadMetric
from .head_metrics_snapshot import HeadMetricsSnapshot from .head_metrics_snapshot import HeadMetricsSnapshot
from .head_training_run import HeadTrainingRun from .head_training_run import HeadTrainingRun
from .image_prediction import ImagePrediction
from .image_provenance import ImageProvenance from .image_provenance import ImageProvenance
from .image_record import ImageRecord from .image_record import ImageRecord
from .image_region import ImageRegion from .image_region import ImageRegion
@@ -34,7 +33,6 @@ from .subscribestar_failed_media import SubscribeStarFailedMedia
from .subscribestar_seen_media import SubscribeStarSeenMedia from .subscribestar_seen_media import SubscribeStarSeenMedia
from .tag import Tag, TagKind, image_tag from .tag import Tag, TagKind, image_tag
from .tag_alias import TagAlias from .tag_alias import TagAlias
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
@@ -59,7 +57,6 @@ __all__ = [
"SeriesPage", "SeriesPage",
"SeriesSuggestion", "SeriesSuggestion",
"ImageRecord", "ImageRecord",
"ImagePrediction",
"ImageProvenance", "ImageProvenance",
"ImageRegion", "ImageRegion",
"Tag", "Tag",
@@ -78,7 +75,6 @@ __all__ = [
"HeadMetricsSnapshot", "HeadMetricsSnapshot",
"HeadTrainingRun", "HeadTrainingRun",
"TagAlias", "TagAlias",
"TagAllowlist",
"TagEvalRun", "TagEvalRun",
"TagHead", "TagHead",
"TagPositiveConfirmation", "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()
)
-10
View File
@@ -1479,16 +1479,6 @@ class Importer:
existing.siglip_embedding = None existing.siglip_embedding = None
existing.siglip_model_version = None existing.siglip_model_version = None
existing.centroid_scores = None existing.centroid_scores = None
# #768: predictions also live in the normalized image_prediction table
# now — clear them so a re-imported file re-derives a fresh set.
from sqlalchemy import delete as _delete
from ..models import ImagePrediction as _ImagePrediction
self.session.execute(
_delete(_ImagePrediction).where(
_ImagePrediction.image_record_id == existing.id
)
)
# created_at intentionally preserved; updated_at auto-bumps. # created_at intentionally preserved; updated_at auto-bumps.
self.session.flush() self.session.flush()
self.session.commit() self.session.commit()
+17 -152
View File
@@ -1,36 +1,20 @@
"""Allowlist semantics: accepting a suggestion adds the canonical tag to """Suggestion actions: accept applies the canonical tag to an image (which
image_tag AND to tag_allowlist; per-image removal/dismiss writes a rejection. feeds head training); dismiss / reject record a per-image rejection.
(The Camie allowlist bulk-apply was retired #1189 — heads + CCIP are the tag
source, and head auto-apply is the earned propagation. Accept no longer
allowlists or fans a tag out across the library.)
""" """
from collections.abc import Sequence from sqlalchemy import delete
from dataclasses import dataclass
from sqlalchemy import and_, delete, distinct, func, or_, select
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ...models import ( from ...models import TagSuggestionRejection
ImagePrediction,
MLSettings,
Tag,
TagAlias,
TagAllowlist,
TagSuggestionRejection,
)
from ...models.tag import image_tag from ...models.tag import image_tag
from .aliases import AliasService from .aliases import AliasService
@dataclass(frozen=True)
class AllowlistRow:
tag_id: int
tag_name: str
tag_kind: str
min_confidence: float
applied_count: int # image_tag rows currently carrying this tag
coverage_count: int # images a sweep WOULD cover at min_confidence
class AllowlistService: class AllowlistService:
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
self.session = session self.session = session
@@ -39,21 +23,11 @@ class AllowlistService:
async def _apply_image_tag(self, image_id: int, tag_id: int, source: str): async def _apply_image_tag(self, image_id: int, tag_id: int, source: str):
stmt = insert(image_tag).values( stmt = insert(image_tag).values(
image_record_id=image_id, tag_id=tag_id, source=source image_record_id=image_id, tag_id=tag_id, source=source
) ).on_conflict_do_nothing(
stmt = stmt.on_conflict_do_nothing(
index_elements=["image_record_id", "tag_id"] index_elements=["image_record_id", "tag_id"]
) )
await self.session.execute(stmt) await self.session.execute(stmt)
async def _add_to_allowlist(self, tag_id: int) -> bool:
"""Returns True if newly added (caller should kick off retro-apply)."""
exists = await self.session.get(TagAllowlist, tag_id)
if exists is not None:
return False
self.session.add(TagAllowlist(tag_id=tag_id))
await self.session.flush()
return True
async def _clear_rejection(self, image_id: int, tag_id: int): async def _clear_rejection(self, image_id: int, tag_id: int):
await self.session.execute( await self.session.execute(
delete(TagSuggestionRejection) delete(TagSuggestionRejection)
@@ -61,12 +35,11 @@ class AllowlistService:
.where(TagSuggestionRejection.tag_id == tag_id) .where(TagSuggestionRejection.tag_id == tag_id)
) )
async def accept(self, image_id: int, tag_id: int) -> bool: async def accept(self, image_id: int, tag_id: int) -> None:
"""Accept a suggestion. Returns True if the tag was newly added to """Apply the accepted tag to this image (source='ml_accepted', a head
the allowlist (the API layer enqueues apply_allowlist_tags then).""" training positive) and clear any prior rejection."""
await self._apply_image_tag(image_id, tag_id, source="ml_accepted") await self._apply_image_tag(image_id, tag_id, source="ml_accepted")
await self._clear_rejection(image_id, tag_id) await self._clear_rejection(image_id, tag_id)
return await self._add_to_allowlist(tag_id)
async def add_alias_and_accept( async def add_alias_and_accept(
self, self,
@@ -74,17 +47,16 @@ class AllowlistService:
alias_string: str, alias_string: str,
alias_category: str, alias_category: str,
canonical_tag_id: int, canonical_tag_id: int,
) -> bool: ) -> None:
await self.aliases.create( await self.aliases.create(
alias_string, alias_category, canonical_tag_id alias_string, alias_category, canonical_tag_id
) )
return await self.accept(image_id, canonical_tag_id) await self.accept(image_id, canonical_tag_id)
async def dismiss(self, image_id: int, tag_id: int) -> None: async def dismiss(self, image_id: int, tag_id: int) -> None:
stmt = insert(TagSuggestionRejection).values( stmt = insert(TagSuggestionRejection).values(
image_record_id=image_id, tag_id=tag_id image_record_id=image_id, tag_id=tag_id
) ).on_conflict_do_nothing(
stmt = stmt.on_conflict_do_nothing(
index_elements=["image_record_id", "tag_id"] index_elements=["image_record_id", "tag_id"]
) )
await self.session.execute(stmt) await self.session.execute(stmt)
@@ -96,118 +68,11 @@ class AllowlistService:
await self._clear_rejection(image_id, tag_id) await self._clear_rejection(image_id, tag_id)
async def reject_applied_tag(self, image_id: int, tag_id: int) -> None: async def reject_applied_tag(self, image_id: int, tag_id: int) -> None:
"""Operator removed an applied tag from an image. Remove the """Operator removed an applied tag from an image. Remove the image_tag
image_tag row AND record a rejection so the allowlist won't row AND record a rejection so head auto-apply won't re-apply it."""
re-apply it on the next maintenance sweep."""
await self.session.execute( await self.session.execute(
image_tag.delete() image_tag.delete()
.where(image_tag.c.image_record_id == image_id) .where(image_tag.c.image_record_id == image_id)
.where(image_tag.c.tag_id == tag_id) .where(image_tag.c.tag_id == tag_id)
) )
await self.dismiss(image_id, tag_id) await self.dismiss(image_id, tag_id)
async def _store_floor(self) -> float:
return (
await self.session.execute(
select(MLSettings.tagger_store_floor).where(MLSettings.id == 1)
)
).scalar_one()
async def update_threshold(
self, tag_id: int, min_confidence: float
) -> None:
row = await self.session.get(TagAllowlist, tag_id)
if row is not None:
# An allowlist tag can't auto-apply more permissively than the
# ingest store floor — predictions below tagger_store_floor aren't
# stored, so a lower min_confidence would behave identically to the
# floor. Clamp so the stored threshold matches actual behavior
# (#764).
floor = await self._store_floor()
row.min_confidence = max(min_confidence, floor)
async def remove(self, tag_id: int) -> None:
await self.session.execute(
delete(TagAllowlist).where(TagAllowlist.tag_id == tag_id)
)
async def _coverage_match(self, tag: Tag):
"""The predicate over image_prediction rows that resolve to `tag`,
mirroring tasks.ml._confidence_for_tag's resolution: a prediction whose
raw_name equals the tag name (any category), OR an alias maps
(raw_name, category) -> this tag. Returns a SQLAlchemy boolean clause.
"""
alias_rows = (
await self.session.execute(
select(TagAlias.alias_string, TagAlias.alias_category).where(
TagAlias.canonical_tag_id == tag.id
)
)
).all()
name_clause = ImagePrediction.raw_name == tag.name
alias_clauses = [
and_(
ImagePrediction.raw_name == a,
ImagePrediction.category == c,
)
for a, c in alias_rows
]
return or_(name_clause, *alias_clauses) if alias_clauses else name_clause
async def coverage(self, tag_id: int, threshold: float) -> int:
"""How many distinct images a sweep WOULD cover for this tag at
`threshold`: images with a resolving prediction scoring >= threshold.
The gross candidate pool (NOT minus already-applied/rejected) — it's
the tuning signal for "lower the threshold and ~N more images qualify".
"""
tag = await self.session.get(Tag, tag_id)
if tag is None:
return 0
match = await self._coverage_match(tag)
stmt = select(
func.count(distinct(ImagePrediction.image_record_id))
).where(ImagePrediction.score >= threshold, match)
return (await self.session.execute(stmt)).scalar_one()
async def list_all(self) -> Sequence[AllowlistRow]:
stmt = (
select(
TagAllowlist.tag_id,
Tag.name,
Tag.kind,
TagAllowlist.min_confidence,
)
.join(Tag, Tag.id == TagAllowlist.tag_id)
.order_by(Tag.name.asc())
)
rows = (await self.session.execute(stmt)).all()
tag_ids = [r[0] for r in rows]
# Applied counts in ONE grouped query (vs N per-row counts).
applied: dict[int, int] = {}
if tag_ids:
applied = dict(
(
await self.session.execute(
select(image_tag.c.tag_id, func.count())
.where(image_tag.c.tag_id.in_(tag_ids))
.group_by(image_tag.c.tag_id)
)
).all()
)
result = []
for r in rows:
# Coverage is per-tag (alias set differs); allowlist is small.
cov = await self.coverage(r[0], r[3])
result.append(
AllowlistRow(
tag_id=r[0],
tag_name=r[1],
tag_kind=r[2].value if hasattr(r[2], "value") else str(r[2]),
min_confidence=r[3],
applied_count=applied.get(r[0], 0),
coverage_count=cov,
)
)
return result
-210
View File
@@ -1,210 +0,0 @@
"""Camie-tagger-v2 ONNX wrapper (CPU).
Single-image at a time. Loaded lazily inside the ml-worker process; NOT
thread-safe — the ml queue worker runs --concurrency=1 per process (scale ML by
running multiple worker replicas, not threads).
v2 layout reference: HuggingFace Camais03/camie-tagger-v2 root has
camie-tagger-v2.onnx (789 MB) + camie-tagger-v2-metadata.json (7.77 MB)
+ config.json. Tags ship as nested JSON, not CSV. Preprocessing and
output handling follow the published onnx_inference.py reference:
ImageNet normalize, NCHW layout, sigmoid on refined logits (output[1]).
"""
import json
import os
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from PIL import Image, ImageFile
# Cap inference threads (see Tagger.load) so each ml-worker replica is a bounded
# core consumer on a shared node — keep N_replicas × this within the cores
# allotted to ML so replicas don't oversubscribe the box / starve the DB.
_INTRA_OP_THREADS = 4
# onnxruntime lives in requirements-ml.txt only — it is NOT installed in the
# lean web image or in CI. Imported lazily inside Tagger.load() so this module
# imports fine without it (the suggestion service imports SURFACED_CATEGORIES
# from here in the web container, and CI collects the pure-logic tests).
# Tolerate minutely-truncated source images (same rationale as IR's wd14.py:
# a few missing bytes at the JPEG EOI shouldn't block tagging the whole image).
ImageFile.LOAD_TRUNCATED_IMAGES = True
MODEL_NAME = os.environ.get("CAMIE_MODEL_NAME", "camie-tagger-v2")
_MODEL_DIR = Path(os.environ.get("ML_MODEL_DIR", "/models")) / "camie"
_MODEL_FILE = f"{MODEL_NAME}.onnx"
_METADATA_FILE = f"{MODEL_NAME}-metadata.json"
# Ingest floor below which predictions aren't stored (keeps the JSON compact).
# DEFAULT/fallback only — the live value is DB-backed
# (ml_settings.tagger_store_floor) and passed into infer() per call by the ml
# task. 0.70: the suggestion path already filters there and the centroid path
# covers lower-confidence preferred tags, so the sub-0.70 tail is redundant
# (it had bloated image_record's TOAST to ~100 GB; plan-task #764).
DEFAULT_STORE_FLOOR = 0.70
# The categories FC-2b surfaces in the UI. Others (meta/rating/year) are
# still stored but the suggestion service filters them out.
# 'artist' retired in FC-2d-vii-c — artist identity is acquisition-derived
# (image_record.artist_id), never ML-inferred. 'copyright' retired
# 2026-06-01 — operator doesn't use the copyright tag-kind; fandom is
# this app's franchise/series concept (per TagsView.vue's doc comment).
# Raw predictions for both categories still get stored at STORE_FLOOR but
# don't surface in suggestions.
SURFACED_CATEGORIES = {"character", "general"}
# ImageNet preprocessing constants (per Camie v2 onnx_inference.py).
_IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
_IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
# Square-pad color ≈ ImageNet mean × 255 (matches reference inference).
_PAD_COLOR = (124, 116, 104)
@dataclass(frozen=True)
class TagPrediction:
name: str
category: str
confidence: float
class Tagger:
def __init__(self, model_dir: Path | None = None):
self._model_dir = model_dir or _MODEL_DIR
self._session = None # onnxruntime.InferenceSession once load()ed
self._tag_names: list[str] | None = None
self._tag_categories: list[str] | None = None
self._input_name: str | None = None
self._input_size: int = 512
def load(self) -> None:
if self._session is not None:
return
model_path = self._model_dir / _MODEL_FILE
meta_path = self._model_dir / _METADATA_FILE
if not model_path.is_file():
raise RuntimeError(
f"Camie {_MODEL_FILE} missing at {model_path}. "
f"Populate /models via the ml-worker downloader."
)
if not meta_path.is_file():
raise RuntimeError(
f"Camie {_METADATA_FILE} missing at {meta_path}. "
f"Populate /models via the ml-worker downloader."
)
with open(meta_path) as f:
metadata = json.load(f)
# Per Camie v2 onnx_inference.py: idx_to_tag is keyed by str(idx);
# tag_to_category maps tag_name -> category. Project to two parallel
# lists indexed by output position for O(1) lookup in the hot path.
ds = metadata["dataset_info"]
idx_to_tag = ds["tag_mapping"]["idx_to_tag"]
tag_to_category = ds["tag_mapping"]["tag_to_category"]
total = ds["total_tags"]
names: list[str] = []
cats: list[str] = []
for i in range(total):
name = idx_to_tag.get(str(i), f"unknown-{i}")
names.append(name)
cats.append(tag_to_category.get(name, "general"))
# Input size from metadata; fall back to 512 (the v2 default).
self._input_size = int(
metadata.get("model_info", {}).get("img_size", 512)
)
# Lazy import — kept after the file-existence checks so the
# missing-model RuntimeError still fires first in environments
# without onnxruntime (CI / lean web image).
import onnxruntime as ort
# Cap the intra-op thread pool. ONNX Runtime otherwise sizes it to ALL
# host cores, so on a shared node each ml-worker replica would grab every
# core and oversubscribe (and starve the co-located DB/web). Bounding it
# makes each replica a predictable core consumer — run N replicas where
# N × _INTRA_OP_THREADS stays within the cores you allot to ML.
opts = ort.SessionOptions()
opts.intra_op_num_threads = _INTRA_OP_THREADS
session = ort.InferenceSession(
str(model_path), sess_options=opts, providers=["CPUExecutionProvider"],
)
self._input_name = session.get_inputs()[0].name
# Assign sentinels last so a partial load isn't observable.
self._tag_names = names
self._tag_categories = cats
self._session = session
def _preprocess(self, image_path: Path) -> np.ndarray:
img = Image.open(image_path)
# Composite RGBA onto neutral so transparency doesn't bias the model.
if img.mode == "RGBA":
bg = Image.new("RGBA", img.size, (255, 255, 255, 255))
bg.paste(img, mask=img.split()[3])
img = bg.convert("RGB")
elif img.mode != "RGB":
img = img.convert("RGB")
# Pad to square with ImageNet-mean color, then bicubic resize.
w, h = img.size
side = max(w, h)
square = Image.new("RGB", (side, side), _PAD_COLOR)
square.paste(img, ((side - w) // 2, (side - h) // 2))
square = square.resize(
(self._input_size, self._input_size), Image.BICUBIC
)
arr = np.array(square, dtype=np.float32) / 255.0 # HWC, [0,1]
arr = (arr - _IMAGENET_MEAN) / _IMAGENET_STD # ImageNet normalize
arr = arr.transpose(2, 0, 1) # HWC -> CHW
return arr[np.newaxis, :, :, :] # NCHW
def infer(
self, image_path: Path, *, store_floor: float = DEFAULT_STORE_FLOOR,
) -> dict[str, TagPrediction]:
"""Run Camie v2 on one image. Returns {name: TagPrediction} with
confidence >= store_floor (across all categories — the suggestion
service does category filtering later). store_floor is the DB-backed
ml_settings.tagger_store_floor, passed in by the ml task.
v2 emits multiple outputs; we use the refined predictions
(output[1] per onnx_inference.py). Sigmoid is applied to raw
logits to produce [0,1] confidence scores.
"""
self.load()
x = self._preprocess(image_path)
outputs = self._session.run(None, {self._input_name: x})
# Refined predictions if present (v2 emits initial + refined),
# fall back to initial for single-output forks.
logits = outputs[1] if len(outputs) > 1 else outputs[0]
# Squeeze batch dim, apply sigmoid.
probs = 1.0 / (1.0 + np.exp(-logits[0]))
results: dict[str, TagPrediction] = {}
names = self._tag_names
cats = self._tag_categories
for idx, score in enumerate(probs):
conf = float(score)
if conf < store_floor:
continue
if idx >= len(names):
# Output longer than metadata declared — shouldn't happen but
# don't crash the import pipeline if v2 metadata desynchronizes.
continue
results[names[idx]] = TagPrediction(
name=names[idx], category=cats[idx], confidence=conf
)
return results
_default_tagger: Tagger | None = None
def get_tagger() -> Tagger:
"""Process-level singleton so the ONNX session loads once per worker."""
global _default_tagger
if _default_tagger is None:
_default_tagger = Tagger()
return _default_tagger
+5 -30
View File
@@ -10,7 +10,6 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession 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 .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
@@ -303,28 +302,22 @@ 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) — pipeline has ever applied it (manual accept or head auto-apply) — so a
otherwise the proactive apply_allowlist_tags worker would silently re-application or an alias remap resolves the canonical name. Purely-
regenerate it. Purely-manual, ML-unknown tags are deleted outright (no manual, ML-unknown tags are 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(
and_( and_(
image_tag.c.tag_id == tag_id, image_tag.c.tag_id == tag_id,
image_tag.c.source.in_( image_tag.c.source.in_(
("ml_auto", "ml_accepted", "auto") ("ml_auto", "ml_accepted", "head_auto", "auto")
), ),
) )
) )
) )
) )
if is_machine: return bool(is_machine)
return True
allowlisted = await self.session.scalar(
select(exists().where(TagAllowlist.tag_id == tag_id))
)
return bool(allowlisted)
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
@@ -564,7 +557,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_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
@@ -630,23 +622,6 @@ class TagService:
.values(tag_id=tgt) .values(tag_id=tgt)
) )
async def _repoint_allowlist(self, src: int, tgt: int) -> None:
tgt_has = await self.session.scalar(
select(exists().where(TagAllowlist.tag_id == tgt))
)
if tgt_has:
await self.session.execute(
text("DELETE FROM tag_allowlist WHERE tag_id = :src"),
{"src": src},
)
else:
await self.session.execute(
update(TagAllowlist)
.where(TagAllowlist.tag_id == src)
.values(tag_id=tgt)
)
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
+24 -261
View File
@@ -1,20 +1,19 @@
"""ML Celery tasks: per-image inference, backfill discovery, head training, """ML Celery tasks: per-image embedding, backfill discovery, head training,
allowlist auto-apply, model self-heal. model self-heal.
All run on the ml-worker (queue 'ml') except apply_allowlist_tags sweeps which All run on the ml-worker (queue 'ml'). Sync sessions (Celery workers are sync
are 'maintenance' lane. Sync sessions (Celery workers are sync processes), same processes), same pattern as FC-2a tasks.
pattern as FC-2a tasks.
""" """
import logging import logging
from pathlib import Path from pathlib import Path
from celery.exceptions import SoftTimeLimitExceeded from celery.exceptions import SoftTimeLimitExceeded
from sqlalchemy import delete, select from sqlalchemy import select
from sqlalchemy.exc import DBAPIError, OperationalError from sqlalchemy.exc import DBAPIError, OperationalError
from ..celery_app import celery from ..celery_app import celery
from ..models import ImagePrediction, ImageRecord, MLSettings from ..models import ImageRecord, MLSettings
from ._sync_engine import sync_session_factory as _sync_session_factory from ._sync_engine import sync_session_factory as _sync_session_factory
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -46,19 +45,16 @@ def _is_video(path: Path) -> bool:
time_limit=1200, # 20 min hard time_limit=1200, # 20 min hard
) )
def tag_and_embed(self, image_id: int) -> dict: def tag_and_embed(self, image_id: int) -> dict:
"""Run Camie + SigLIP on one image; store predictions + embedding; """Compute + store one image's SigLIP embedding.
then enqueue per-image allowlist application.
Video (#747): sample frames at a fixed cadence (ml_settings Video (#747): sample frames at a fixed cadence (ml_settings
video_frame_interval_seconds, capped at video_max_frames), keep a tag only if video_frame_interval_seconds, capped at video_max_frames) and mean-pool the
it appears in >= video_min_tag_frames frames and average its confidence over per-frame SigLIP embeddings. On no-frames returns status='no_frames' (not an
those frames (mean-pool, not max — kills one-frame noise); mean-pool the error). (Camie tagging was retired #1189 — heads + CCIP are the tag source.)
SigLIP embeddings. On no-frames returns status='no_frames' (not an error).
""" """
import time import time
from ..services.ml.embedder import get_embedder from ..services.ml.embedder import get_embedder
from ..services.ml.tagger import get_tagger
# Phase + file context, so a timeout/crash names WHICH file and WHERE it # Phase + file context, so a timeout/crash names WHICH file and WHERE it
# died instead of a bare SoftTimeLimitExceeded() (operator-flagged 2026-06-08: # died instead of a bare SoftTimeLimitExceeded() (operator-flagged 2026-06-08:
@@ -94,15 +90,13 @@ def tag_and_embed(self, image_id: int) -> dict:
return {"status": "file_missing", "image_id": image_id} return {"status": "file_missing", "image_id": image_id}
phase = "load_models" phase = "load_models"
tagger = get_tagger()
embedder = get_embedder(settings.embedder_model_name) embedder = get_embedder(settings.embedder_model_name)
if is_vid: if is_vid:
# Layer-3 isolation: ffprobe (a separate process) validates # Layer-3 isolation: ffprobe (a separate process) validates
# the container before we burn ~20 GPU ops sampling frames # the container before we burn GPU ops sampling frames from it.
# from it. A corrupt video that would crash the frame # A corrupt video that would crash the frame decoder is rejected
# decoder is rejected cleanly here instead of taking down # cleanly here instead of taking down the ml-worker.
# the ml-worker. Operator-flagged 2026-05-28.
phase = "video_probe" phase = "video_probe"
from ..utils import safe_probe from ..utils import safe_probe
vprobe = safe_probe.probe_video(src) vprobe = safe_probe.probe_video(src)
@@ -115,48 +109,23 @@ def tag_and_embed(self, image_id: int) -> dict:
"reason": vprobe.reason, "reason": vprobe.reason,
} }
phase = "video_sample_frames" phase = "video_sample_frames"
t0 = time.monotonic()
frames = _sample_video_frames( frames = _sample_video_frames(
src, src,
interval=settings.video_frame_interval_seconds, interval=settings.video_frame_interval_seconds,
max_frames=settings.video_max_frames, max_frames=settings.video_max_frames,
) )
log.info(
"tag_and_embed sampled %d frame(s) in %.1fs: %s",
len(frames), time.monotonic() - t0, ctx,
)
if not frames: if not frames:
return {"status": "no_frames", "image_id": image_id} return {"status": "no_frames", "image_id": image_id}
phase = "video_infer" phase = "video_embed"
import numpy as np import numpy as np
preds = _aggregate_video_predictions( # Mean-pool the per-frame SigLIP embeddings into one vector.
[tagger.infer(f, store_floor=settings.tagger_store_floor)
for f in frames],
min_frames=settings.video_min_tag_frames,
)
embedding = np.mean( embedding = np.mean(
[embedder.infer(f) for f in frames], axis=0 [embedder.infer(f) for f in frames], axis=0
).astype("float32") ).astype("float32")
log.info(
"tag_and_embed video aggregated %d tag(s) from %d frame(s) "
"(min_frames=%d): %s",
len(preds), len(frames), settings.video_min_tag_frames, ctx,
)
for f in frames: for f in frames:
f.unlink(missing_ok=True) f.unlink(missing_ok=True)
else: else:
phase = "tag"
t0 = time.monotonic()
raw = tagger.infer(src, store_floor=settings.tagger_store_floor)
log.info(
"tag_and_embed tagged in %.1fs (%d tags): %s",
time.monotonic() - t0, len(raw), ctx,
)
preds = {
name: {"category": p.category, "confidence": p.confidence}
for name, p in raw.items()
}
phase = "embed" phase = "embed"
t0 = time.monotonic() t0 = time.monotonic()
embedding = embedder.infer(src) embedding = embedder.infer(src)
@@ -166,28 +135,9 @@ def tag_and_embed(self, image_id: int) -> dict:
) )
phase = "persist" phase = "persist"
record.tagger_model_version = settings.tagger_model_version
record.siglip_embedding = embedding.tolist() record.siglip_embedding = embedding.tolist()
record.siglip_model_version = settings.embedder_model_version record.siglip_model_version = settings.embedder_model_version
session.add(record) session.add(record)
# Write the normalized image_prediction rows (#768) — the sole home
# for predictions now (image_record.tagger_predictions was dropped in
# migration 0046). Delete-then-insert keeps a re-tag idempotent;
# tagger_store_floor was already applied in tagger.infer, so preds is
# the >=floor set.
session.execute(
delete(ImagePrediction).where(
ImagePrediction.image_record_id == image_id
)
)
session.add_all([
ImagePrediction(
image_record_id=image_id, raw_name=name,
category=p.get("category", "general"),
score=float(p.get("confidence", 0.0)),
)
for name, p in preds.items()
])
session.commit() session.commit()
except SoftTimeLimitExceeded: except SoftTimeLimitExceeded:
log.error( log.error(
@@ -210,11 +160,8 @@ def tag_and_embed(self, image_id: int) -> dict:
) )
raise raise
log.info( log.info("tag_and_embed ok in %.1fs: %s", _elapsed(), ctx)
"tag_and_embed ok in %.1fs (%d tags): %s", _elapsed(), len(preds), ctx return {"status": "ok", "image_id": image_id}
)
apply_allowlist_tags.delay(image_id=image_id)
return {"status": "ok", "image_id": image_id, "tags": len(preds)}
def _sample_video_frames( def _sample_video_frames(
@@ -273,68 +220,24 @@ def _sample_video_frames(
return out return out
def _aggregate_video_predictions(per_frame: list[dict], *, min_frames: int) -> dict:
"""Aggregate per-frame {name: TagPrediction} into one prediction set (#747).
A tag is kept only if it appears (≥ the tagger store floor, already applied)
in at least `min_frames` of the sampled frames — because sampling is at a
fixed cadence, that means it was on screen for roughly min_frames×interval
seconds, so a single-frame flicker / scene-transition artifact is dropped
while a genuine scene-local tag in a long video survives. Confidence is the
MEAN over the frames where the tag appears (not max — max re-inflated the
one-frame noise this whole change exists to remove).
`min_frames` is clamped to the number of frames actually sampled so a very
short video (12 frames) still tags instead of dropping everything.
"""
n = len(per_frame)
if n == 0:
return {}
threshold = max(1, min(min_frames, n))
agg: dict[str, dict] = {}
for frame_preds in per_frame:
for name, p in frame_preds.items():
cur = agg.get(name)
if cur is None:
agg[name] = {"category": p.category, "sum": p.confidence, "count": 1}
else:
cur["sum"] += p.confidence
cur["count"] += 1
return {
name: {"category": v["category"], "confidence": v["sum"] / v["count"]}
for name, v in agg.items()
if v["count"] >= threshold
}
@celery.task(name="backend.app.tasks.ml.backfill", bind=True) @celery.task(name="backend.app.tasks.ml.backfill", bind=True)
def backfill(self) -> int: def backfill(self) -> int:
"""Enqueue tag_and_embed for images missing predictions/embeddings for """Enqueue tag_and_embed (embed-only) for images with no SigLIP embedding.
the current model versions. Keyset pagination by id ASC (restart-safe). Keyset pagination by id ASC (restart-safe).
NB: a siglip MODEL-VERSION mismatch (an operator model swap, #1190) is NOT
re-embedded here — the CPU ml-worker can't churn the library at 384/512px;
the GPU agent owns version re-embeds via the 'embed' job.
""" """
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
enqueued = 0 enqueued = 0
last_id = 0 last_id = 0
with SessionLocal() as session: with SessionLocal() as session:
settings = session.execute(
select(MLSettings).where(MLSettings.id == 1)
).scalar_one()
while True: while True:
rows = session.execute( rows = session.execute(
select(ImageRecord.id) select(ImageRecord.id)
.where(ImageRecord.id > last_id) .where(ImageRecord.id > last_id)
.where( .where(ImageRecord.siglip_embedding.is_(None))
(ImageRecord.tagger_model_version.is_(None))
| (
ImageRecord.tagger_model_version
!= settings.tagger_model_version
)
| (ImageRecord.siglip_embedding.is_(None))
# NB: a siglip MODEL-VERSION mismatch (an operator model swap,
# #1190) is intentionally NOT re-embedded here — the CPU
# ml-worker can't churn the whole library at 384/512px. The
# GPU agent owns version re-embeds via the 'embed' job.
)
.order_by(ImageRecord.id.asc()) .order_by(ImageRecord.id.asc())
.limit(500) .limit(500)
).scalars().all() ).scalars().all()
@@ -347,146 +250,6 @@ def backfill(self) -> int:
return enqueued return enqueued
@celery.task(
name="backend.app.tasks.ml.apply_allowlist_tags",
bind=True,
# Audit 2026-06-02 — the full-sweep mode (neither tag_id nor image_id)
# is O(images × allowlist) and legitimately runs >5 min on large
# libraries. Cap matches the maintenance queue's recovery threshold.
soft_time_limit=1800, time_limit=2100,
)
def apply_allowlist_tags(self, tag_id: int | None = None,
image_id: int | None = None) -> int:
"""Retroactively apply allowlisted tags.
Modes:
- tag_id only : scan all images for this tag.
- image_id only : scan all allowlisted tags for this image.
- both : just the (image, tag) pair.
- neither : full sweep (daily beat).
Skips: already-applied, rejected (tag_suggestion_rejection), or
confidence below the tag's allowlist min_confidence. Applied with
source='ml_auto'.
"""
from sqlalchemy import and_
from sqlalchemy import select as sa_select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from ..models import TagAllowlist, TagSuggestionRejection
from ..models.tag import image_tag
SessionLocal = _sync_session_factory()
applied = 0
with SessionLocal() as session:
allow_rows = session.execute(
sa_select(TagAllowlist.tag_id, TagAllowlist.min_confidence)
if tag_id is None
else sa_select(
TagAllowlist.tag_id, TagAllowlist.min_confidence
).where(TagAllowlist.tag_id == tag_id)
).all()
allow = {r[0]: r[1] for r in allow_rows}
if not allow:
return 0
# Images that have any predictions (#768: from image_prediction, not
# the old JSON column), optionally narrowed to one image.
img_ids_query = sa_select(ImagePrediction.image_record_id).distinct()
if image_id is not None:
img_ids_query = img_ids_query.where(
ImagePrediction.image_record_id == image_id
)
for (img_id,) in session.execute(img_ids_query).all():
preds = _load_predictions_sync(session, img_id)
for a_tag_id, min_conf in allow.items():
exists = session.execute(
sa_select(image_tag.c.tag_id).where(
and_(
image_tag.c.image_record_id == img_id,
image_tag.c.tag_id == a_tag_id,
)
)
).scalar_one_or_none()
if exists is not None:
continue
rej = session.get(
TagSuggestionRejection, (img_id, a_tag_id)
)
if rej is not None:
continue
from ..models import Tag
tag = session.get(Tag, a_tag_id)
if tag is None:
continue
conf = _confidence_for_tag(session, tag, preds)
if conf is None or conf < min_conf:
continue
stmt = pg_insert(image_tag).values(
image_record_id=img_id,
tag_id=a_tag_id,
source="ml_auto",
)
stmt = stmt.on_conflict_do_nothing(
index_elements=["image_record_id", "tag_id"]
)
session.execute(stmt)
applied += 1
session.commit()
return applied
def _load_predictions_sync(session, image_id: int) -> dict:
"""Predictions for one image from image_prediction (#768), in the
{raw_name: {category, confidence}} shape _confidence_for_tag consumes —
keeps the allowlist resolution logic unchanged."""
from sqlalchemy import select as sa_select
rows = session.execute(
sa_select(
ImagePrediction.raw_name,
ImagePrediction.category,
ImagePrediction.score,
).where(ImagePrediction.image_record_id == image_id)
).all()
return {
r.raw_name: {"category": r.category, "confidence": r.score}
for r in rows
}
def _confidence_for_tag(session, tag, preds: dict) -> float | None:
"""Highest confidence among predictions that resolve to `tag` —
either the prediction name equals the tag name, or an alias maps
(prediction name, category) -> tag.id.
"""
from sqlalchemy import select as sa_select
from ..models import TagAlias
best: float | None = None
direct = preds.get(tag.name)
if direct is not None:
best = float(direct.get("confidence", 0.0))
alias_rows = session.execute(
sa_select(TagAlias.alias_string, TagAlias.alias_category).where(
TagAlias.canonical_tag_id == tag.id
)
).all()
for alias_string, alias_category in alias_rows:
p = preds.get(alias_string)
if p is None:
continue
if p.get("category") != alias_category:
continue
c = float(p.get("confidence", 0.0))
if best is None or c > best:
best = c
return best
@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,120 +0,0 @@
<template>
<MaintenanceTile
icon="mdi-playlist-check"
:title="`Allowlisted tags (${store.rows.length})`"
blurb="Tags auto-applied to images that score above their threshold. Tune the
threshold and see how many images it would cover."
>
<v-data-table-virtual
:headers="headers" :items="store.rows" :loading="store.loading"
height="360" density="compact" fixed-header
no-data-text="No tags on the allowlist yet accept a suggestion to add one."
>
<template #item.applied_count="{ item }">
<span class="fc-num">{{ item.applied_count ?? '—' }}</span>
</template>
<template #item.min_confidence="{ item }">
<div class="fc-thr">
<v-text-field
:model-value="item.min_confidence" type="number"
density="compact" hide-details style="max-width: 100px;"
:min="floor" max="1" step="0.05"
:aria-label="`Auto-apply threshold for ${item.tag_name}`"
@update:model-value="(v) => onThreshold(item, v)"
/>
<span
v-if="proj[item.tag_id]"
class="fc-thr__proj"
:class="{ 'fc-thr__proj--loading': proj[item.tag_id].loading }"
:title="`At ${proj[item.tag_id].threshold}, a sweep would cover this many images`"
>≈ {{ proj[item.tag_id].count }} at {{ proj[item.tag_id].threshold }}</span>
</div>
</template>
<template #item.coverage_count="{ item }">
<span class="fc-num" :title="`Images a sweep covers at ${item.min_confidence}`">
{{ item.coverage_count ?? '—' }}
</span>
</template>
<template #item.actions="{ item }">
<v-btn
icon="mdi-delete" size="x-small" variant="text" color="error"
:aria-label="`Remove ${item.tag_name} from the allowlist`"
@click="store.remove(item.tag_id)"
/>
</template>
</v-data-table-virtual>
<p class="fc-muted text-caption mt-2">
<strong>Applied</strong> = images currently carrying the tag.
<strong>Covers</strong> = images a sweep would auto-apply it to at the
current threshold. Lower the threshold to cover more (less certain) images.
</p>
</MaintenanceTile>
</template>
<script setup>
import { computed, onMounted, reactive } from 'vue'
import { useAllowlistStore } from '../../stores/allowlist.js'
import { useMLStore } from '../../stores/ml.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
const store = useAllowlistStore()
const ml = useMLStore()
// min_confidence can't be set below the tagger store floor — predictions
// below it aren't stored, so a lower threshold would behave identically to
// the floor. The backend clamps too (#764).
const floor = computed(() => ml.settings?.tagger_store_floor ?? 0.70)
const headers = [
{ title: 'Tag', key: 'tag_name', sortable: true },
{ title: 'Kind', key: 'tag_kind', sortable: true, width: 100 },
{ title: 'Applied', key: 'applied_count', sortable: true, width: 90 },
{ title: 'Min confidence', key: 'min_confidence', sortable: false, width: 220 },
{ title: 'Covers', key: 'coverage_count', sortable: true, width: 90 },
{ title: '', key: 'actions', sortable: false, width: 56 }
]
// Per-row live projection while the operator drags a threshold:
// proj[tagId] = { threshold, count, loading }
const proj = reactive({})
onMounted(() => {
store.load()
if (!ml.settings) ml.loadSettings()
})
const debounces = {}
function onThreshold(item, value) {
const tagId = item.tag_id
const v = Math.max(parseFloat(value), floor.value)
if (!(v > 0 && v <= 1)) return
const shown = Number(v.toFixed(2))
// Optimistic live projection box (loading until the count returns).
proj[tagId] = { threshold: shown, count: proj[tagId]?.count ?? '…', loading: true }
if (debounces[tagId]) clearTimeout(debounces[tagId])
debounces[tagId] = setTimeout(async () => {
try {
const { count } = await store.coverage(tagId, v)
proj[tagId] = { threshold: shown, count, loading: false }
} catch {
delete proj[tagId] // drop the projection rather than show a wrong number
}
// Commit the new threshold (also refreshes the row's stored coverage_count).
store.updateThreshold(tagId, v)
}, 500)
}
</script>
<style scoped>
.fc-num { font-variant-numeric: tabular-nums; }
.fc-thr { display: flex; align-items: center; gap: 10px; }
.fc-thr__proj {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: rgb(var(--v-theme-accent));
white-space: nowrap;
}
.fc-thr__proj--loading { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
</style>
@@ -2,12 +2,13 @@
<MaintenanceTile <MaintenanceTile
icon="mdi-refresh" icon="mdi-refresh"
title="ML backfill" title="ML backfill"
blurb="Re-run tagging + embeddings on images missing them." blurb="Compute SigLIP embeddings on images missing them."
:open="busy" :open="busy"
> >
<p class="text-body-2 mb-3"> <p class="text-body-2 mb-3">
Re-run Camie + SigLIP on images missing predictions or embeddings Compute the SigLIP embedding for any image that doesn't have one yet
for the current model versions. Safe to re-run. (CPU). Safe to re-run. To re-embed under a NEW model, use the GPU
agent's "Re-embed library" instead.
</p> </p>
<v-btn color="primary" rounded="pill" :loading="busy" @click="run"> <v-btn color="primary" rounded="pill" :loading="busy" @click="run">
<v-icon start>mdi-refresh</v-icon> Run backfill now <v-icon start>mdi-refresh</v-icon> Run backfill now
@@ -1,8 +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 runs One-off backfills, tagging config and storage tools. Heads train nightly
nightly; the allowlist auto-applies accepted tags. Click a tile to open it. and auto-apply earned tags. Click a tile to open it.
</p> </p>
<section class="fc-section"> <section class="fc-section">
@@ -26,7 +26,6 @@
<MLThresholdSliders /> <MLThresholdSliders />
<HeadsCard /> <HeadsCard />
<GpuAgentCard /> <GpuAgentCard />
<AllowlistTable />
<AliasTable /> <AliasTable />
<TagEvalCard /> <TagEvalCard />
</div> </div>
@@ -53,7 +52,6 @@ import DbMaintenanceCard from './DbMaintenanceCard.vue'
import MLThresholdSliders from './MLThresholdSliders.vue' import MLThresholdSliders from './MLThresholdSliders.vue'
import HeadsCard from './HeadsCard.vue' import HeadsCard from './HeadsCard.vue'
import GpuAgentCard from './GpuAgentCard.vue' import GpuAgentCard from './GpuAgentCard.vue'
import AllowlistTable from './AllowlistTable.vue'
import AliasTable from './AliasTable.vue' import AliasTable from './AliasTable.vue'
import TagEvalCard from './TagEvalCard.vue' import TagEvalCard from './TagEvalCard.vue'
import BackupCard from './BackupCard.vue' import BackupCard from './BackupCard.vue'
-44
View File
@@ -1,44 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
export const useAllowlistStore = defineStore('allowlist', () => {
const api = useApi()
const rows = ref([])
const loading = ref(false)
async function load() {
loading.value = true
try { rows.value = await api.get('/api/allowlist') }
finally { loading.value = false }
}
async function updateThreshold(tagId, minConfidence) {
await api.patch(`/api/tags/${tagId}/allowlist`, {
body: { min_confidence: minConfidence }
})
const r = rows.value.find(x => x.tag_id === tagId)
if (r) {
r.min_confidence = minConfidence
// The committed threshold changed the covered pool — refresh the row's
// coverage so the table stays truthful after a save.
try { r.coverage_count = (await coverage(tagId, minConfidence)).count }
catch { /* leave the stale count rather than blank it */ }
}
}
// Live "at threshold T, a sweep would cover ~N images" projection for the
// tuning dashboard. Returns { count, threshold }.
async function coverage(tagId, threshold) {
return api.get(`/api/tags/${tagId}/allowlist/coverage`, {
params: { threshold }
})
}
async function remove(tagId) {
await api.delete(`/api/tags/${tagId}/allowlist`)
rows.value = rows.value.filter(x => x.tag_id !== tagId)
}
return { rows, loading, load, updateThreshold, coverage, remove }
})
+9 -18
View File
@@ -113,7 +113,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
}) })
tagId = created.id tagId = created.id
} }
const res = await api.post(`/api/images/${imageId}/suggestions/accept`, { await api.post(`/api/images/${imageId}/suggestions/accept`, {
body: { tag_id: tagId } body: { tag_id: tagId }
}) })
// Only drop from THIS image's category list — if the user navigated, // Only drop from THIS image's category list — if the user navigated,
@@ -121,23 +121,14 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
if (currentImageId === imageId) { if (currentImageId === imageId) {
_dropEverywhere(suggestion) _dropEverywhere(suggestion)
} }
_acceptToast('Tagged', suggestion.display_name, res) _acceptToast('Tagged', suggestion.display_name)
} }
// One non-blocking toast for accept/alias. When the accept newly allowlisted // One non-blocking toast for accept/alias. The accepted tag is applied to this
// the tag, surface the coverage PROJECTION (#7) so the operator sees the // image and feeds head training; head auto-apply handles propagation (earned),
// auto-apply reach without a blocking pre-accept preview — the apply itself // so there's no instant fan-out to project.
// runs async, hence "~N". function _acceptToast(verb, displayName) {
function _acceptToast(verb, displayName, res) { toast({ text: `${verb}: ${displayName}`, type: 'success' })
if (res?.allowlisted) {
const n = res.projected_count
toast({
text: `${verb}: ${displayName} — allowlisted, auto-applying to ~${n} image${n === 1 ? '' : 's'}`,
type: 'success'
})
} else {
toast({ text: `${verb}: ${displayName}`, type: 'success' })
}
} }
async function aliasAccept(suggestion, canonicalTagId) { async function aliasAccept(suggestion, canonicalTagId) {
@@ -149,7 +140,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
// reappearing unaliased. raw_name is null only for centroid hits, which // reappearing unaliased. raw_name is null only for centroid hits, which
// can't be aliased (the UI hides the action for them). // can't be aliased (the UI hides the action for them).
const aliasString = suggestion.raw_name ?? suggestion.display_name const aliasString = suggestion.raw_name ?? suggestion.display_name
const res = await api.post(`/api/images/${imageId}/suggestions/alias`, { await api.post(`/api/images/${imageId}/suggestions/alias`, {
body: { body: {
alias_string: aliasString, alias_string: aliasString,
alias_category: suggestion.category, alias_category: suggestion.category,
@@ -159,7 +150,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
if (currentImageId === imageId) { if (currentImageId === imageId) {
_dropEverywhere(suggestion) _dropEverywhere(suggestion)
} }
_acceptToast('Aliased & tagged', suggestion.display_name, res) _acceptToast('Aliased & tagged', suggestion.display_name)
} }
// Remove the alias behind an aliased suggestion (the raw prediction reverts to // Remove the alias behind an aliased suggestion (the raw prediction reverts to
-21
View File
@@ -1,21 +0,0 @@
"""#768 test helper: seed image_prediction rows.
Read-path tests used to seed ImageRecord(tagger_predictions={...}); predictions
now live in the normalized image_prediction table, so seed there instead.
"""
from backend.app.models import ImagePrediction
async def seed_predictions(session, image_id: int, predictions: dict) -> None:
"""Insert image_prediction rows from a {raw_name: {category, confidence}}
dict (the old JSON shape). Caller commits/flushes as needed; this flushes."""
session.add_all([
ImagePrediction(
image_record_id=image_id,
raw_name=name,
category=p.get("category", "general"),
score=float(p.get("confidence", 0.0)),
)
for name, p in predictions.items()
])
await session.flush()
-88
View File
@@ -1,88 +0,0 @@
import pytest
from backend.app.models import ImagePrediction, ImageRecord, TagAllowlist, TagKind
from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration
@pytest.mark.asyncio
async def test_list_and_patch_and_delete(client, db):
tag = await TagService(db).find_or_create("AL", TagKind.character)
db.add(TagAllowlist(tag_id=tag.id, min_confidence=0.95))
await db.commit()
resp = await client.get("/api/allowlist")
assert resp.status_code == 200
assert any(r["tag_id"] == tag.id for r in await resp.get_json())
resp = await client.patch(
f"/api/tags/{tag.id}/allowlist", json={"min_confidence": 0.80}
)
assert resp.status_code == 204
resp = await client.get(f"/api/tags/{tag.id}/allowlist")
assert (await resp.get_json())["min_confidence"] == pytest.approx(0.80)
resp = await client.delete(f"/api/tags/{tag.id}/allowlist")
assert resp.status_code == 204
resp = await client.get(f"/api/tags/{tag.id}/allowlist")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_patch_rejects_out_of_range(client, db):
tag = await TagService(db).find_or_create("AL2", TagKind.character)
db.add(TagAllowlist(tag_id=tag.id))
await db.commit()
resp = await client.patch(
f"/api/tags/{tag.id}/allowlist", json={"min_confidence": 1.5}
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_coverage_endpoint(client, db):
tag = await TagService(db).find_or_create("Cover", TagKind.general)
db.add(TagAllowlist(tag_id=tag.id, min_confidence=0.90))
for i, score in enumerate((0.95, 0.60)):
img = ImageRecord(
path=f"/images/cov{i}.jpg", sha256=f"cv{i:062d}", size_bytes=1,
mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
)
db.add(img)
await db.flush()
db.add(ImagePrediction(
image_record_id=img.id, raw_name="Cover",
category="general", score=score,
))
await db.commit()
# Explicit threshold.
resp = await client.get(
f"/api/tags/{tag.id}/allowlist/coverage?threshold=0.90"
)
assert resp.status_code == 200
assert (await resp.get_json())["count"] == 1
# Lower what-if threshold widens coverage.
resp = await client.get(
f"/api/tags/{tag.id}/allowlist/coverage?threshold=0.50"
)
assert (await resp.get_json())["count"] == 2
# No threshold → uses the stored min_confidence (0.90).
resp = await client.get(f"/api/tags/{tag.id}/allowlist/coverage")
body = await resp.get_json()
assert body["count"] == 1
assert body["threshold"] == pytest.approx(0.90)
@pytest.mark.asyncio
async def test_coverage_rejects_bad_threshold(client, db):
tag = await TagService(db).find_or_create("Cover2", TagKind.general)
db.add(TagAllowlist(tag_id=tag.id))
await db.commit()
resp = await client.get(
f"/api/tags/{tag.id}/allowlist/coverage?threshold=2.0"
)
assert resp.status_code == 400
+19 -35
View File
@@ -15,9 +15,7 @@ def eager():
celery.conf.task_always_eager = False celery.conf.task_always_eager = False
async def _img(db, preds, sha="s" * 64): async def _img(db, sha="s" * 64):
from tests._prediction_helpers import seed_predictions
img = ImageRecord( img = ImageRecord(
path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1, path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1,
mime="image/jpeg", width=1, height=1, mime="image/jpeg", width=1, height=1,
@@ -25,8 +23,6 @@ async def _img(db, preds, sha="s" * 64):
) )
db.add(img) db.add(img)
await db.commit() await db.commit()
await seed_predictions(db, img.id, preds)
await db.commit()
return img return img
@@ -60,7 +56,7 @@ async def test_get_suggestions(client, db):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_accept_requires_tag_id(client, db): async def test_accept_requires_tag_id(client, db):
img = await _img(db, {}) img = await _img(db)
resp = await client.post( resp = await client.post(
f"/api/images/{img.id}/suggestions/accept", json={} f"/api/images/{img.id}/suggestions/accept", json={}
) )
@@ -68,43 +64,31 @@ async def test_accept_requires_tag_id(client, db):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_accept_then_applied(client, db): async def test_accept_applies_tag_to_image(client, db):
img = await _img(db, {}) # Camie/allowlist retired (#1189): accept applies the tag to THIS image
# (source='ml_accepted', a head-training positive) — no bulk allowlist
# fan-out anymore.
from backend.app.models.tag import image_tag
img = await _img(db)
tag = await TagService(db).find_or_create("AcceptMe", TagKind.character) tag = await TagService(db).find_or_create("AcceptMe", TagKind.character)
await db.commit() await db.commit()
resp = await client.post( resp = await client.post(
f"/api/images/{img.id}/suggestions/accept", json={"tag_id": tag.id} f"/api/images/{img.id}/suggestions/accept", json={"tag_id": tag.id}
) )
assert resp.status_code == 200 assert resp.status_code == 200
body = await resp.get_json() assert (await resp.get_json())["accepted"] is True
# #7b: a fresh accept newly-allowlists → projection payload for the toast. src = (await db.execute(
assert body["allowlisted"] is True select(image_tag.c.source)
assert body["tag_id"] == tag.id .where(image_tag.c.image_record_id == img.id)
assert body["tag_name"] == "AcceptMe" .where(image_tag.c.tag_id == tag.id)
assert "projected_count" in body )).scalar_one()
assert src == "ml_accepted"
@pytest.mark.asyncio
async def test_accept_already_allowlisted_reports_not_new(client, db):
img1 = await _img(db, {}, sha="c" * 64)
img2 = await _img(db, {}, sha="d" * 64)
tag = await TagService(db).find_or_create("Twice", TagKind.character)
await db.commit()
first = await client.post(
f"/api/images/{img1.id}/suggestions/accept", json={"tag_id": tag.id}
)
assert (await first.get_json())["allowlisted"] is True
second = await client.post(
f"/api/images/{img2.id}/suggestions/accept", json={"tag_id": tag.id}
)
body = await second.get_json()
assert body["allowlisted"] is False # already on the allowlist
assert "projected_count" not in body
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dismiss(client, db): async def test_dismiss(client, db):
img = await _img(db, {}) img = await _img(db)
tag = await TagService(db).find_or_create("DismissMe", TagKind.general) tag = await TagService(db).find_or_create("DismissMe", TagKind.general)
await db.commit() await db.commit()
resp = await client.post( resp = await client.post(
@@ -115,7 +99,7 @@ async def test_dismiss(client, db):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_undismiss_reverses_rejection(client, db): async def test_undismiss_reverses_rejection(client, db):
img = await _img(db, {}) img = await _img(db)
tag = await TagService(db).find_or_create("UndismissMe", TagKind.general) tag = await TagService(db).find_or_create("UndismissMe", TagKind.general)
await db.commit() await db.commit()
await client.post( await client.post(
@@ -134,7 +118,7 @@ async def test_undismiss_reverses_rejection(client, db):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_alias_requires_fields(client, db): async def test_alias_requires_fields(client, db):
img = await _img(db, {}) img = await _img(db)
resp = await client.post( resp = await client.post(
f"/api/images/{img.id}/suggestions/alias", json={"alias_string": "x"} f"/api/images/{img.id}/suggestions/alias", json={"alias_string": "x"}
) )
+1 -39
View File
@@ -68,15 +68,7 @@ async def test_rename_collision_returns_rich_409(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_merge_endpoint_moves_and_deletes(client, monkeypatch): async def test_merge_endpoint_moves_and_deletes(client):
calls = []
from backend.app.tasks import ml as ml_tasks
monkeypatch.setattr(
ml_tasks.apply_allowlist_tags,
"delay",
lambda **kw: calls.append(kw),
)
tgt = await _mk(client, "Keep", "general") tgt = await _mk(client, "Keep", "general")
src = await _mk(client, "Gone", "general") src = await _mk(client, "Gone", "general")
resp = await client.post( resp = await client.post(
@@ -92,36 +84,6 @@ async def test_merge_endpoint_moves_and_deletes(client, monkeypatch):
assert r2.status_code == 200 assert r2.status_code == 200
@pytest.mark.asyncio
async def test_merge_enqueues_backfill_when_target_allowlisted(
client, monkeypatch
):
calls = []
from backend.app.tasks import ml as ml_tasks
monkeypatch.setattr(
ml_tasks.apply_allowlist_tags,
"delay",
lambda **kw: calls.append(kw),
)
tgt = await _mk(client, "AllowTgt", "general")
src = await _mk(client, "AllowSrc", "general")
# No public route adds a tag to the allowlist (it happens via
# accept-suggestion); set the row directly through the app session.
from backend.app.extensions import get_session
from backend.app.models.tag_allowlist import TagAllowlist
async with get_session() as s:
s.add(TagAllowlist(tag_id=tgt))
await s.commit()
resp = await client.post(
f"/api/tags/{src}/merge", json={"target_id": tgt}
)
assert resp.status_code == 200
assert calls == [{"tag_id": tgt}]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_merge_self_is_400(client): async def test_merge_self_is_400(client):
t = await _mk(client, "Selfie", "general") t = await _mk(client, "Selfie", "general")
-57
View File
@@ -1,57 +0,0 @@
"""#768: image_prediction table — model + constraints round-trip."""
import pytest
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from backend.app.models import ImagePrediction, ImageRecord
pytestmark = pytest.mark.integration
async def _make_image(db, path="/img/p0.jpg", sha="0"):
rec = ImageRecord(
path=path, sha256=sha.ljust(64, "0")[:64], size_bytes=10,
mime="image/jpeg", origin="imported_filesystem",
)
db.add(rec)
await db.flush()
return rec
@pytest.mark.asyncio
async def test_image_prediction_round_trip(db):
rec = await _make_image(db)
db.add_all([
ImagePrediction(
image_record_id=rec.id, raw_name="blue_eyes",
category="general", score=0.92,
),
ImagePrediction(
image_record_id=rec.id, raw_name="hatsune_miku",
category="character", score=0.88,
),
])
await db.commit()
rows = (await db.execute(
select(ImagePrediction.raw_name, ImagePrediction.score)
.where(ImagePrediction.image_record_id == rec.id)
.order_by(ImagePrediction.score.desc())
)).all()
assert [r.raw_name for r in rows] == ["blue_eyes", "hatsune_miku"]
@pytest.mark.asyncio
async def test_image_prediction_unique_per_image_name(db):
rec = await _make_image(db, path="/img/p1.jpg", sha="1")
db.add(ImagePrediction(
image_record_id=rec.id, raw_name="dup",
category="general", score=0.9,
))
await db.commit()
db.add(ImagePrediction(
image_record_id=rec.id, raw_name="dup",
category="general", score=0.7,
))
with pytest.raises(IntegrityError):
await db.commit()
-7
View File
@@ -5,14 +5,12 @@ from backend.app.models import (
ImageRecord, ImageRecord,
MLSettings, MLSettings,
TagAlias, TagAlias,
TagAllowlist,
TagSuggestionRejection, TagSuggestionRejection,
) )
def test_new_tables_registered(): def test_new_tables_registered():
expected = { expected = {
"tag_allowlist",
"tag_suggestion_rejection", "tag_suggestion_rejection",
"tag_alias", "tag_alias",
"ml_settings", "ml_settings",
@@ -40,11 +38,6 @@ def test_ml_settings_singleton_constraint():
assert "ck_ml_settings_singleton" in names assert "ck_ml_settings_singleton" in names
def test_tag_allowlist_confidence_check():
names = {c.name for c in TagAllowlist.__table__.constraints}
assert "ck_tag_allowlist_confidence_range" in names
def test_tag_suggestion_rejection_pk(): def test_tag_suggestion_rejection_pk():
pk_cols = {c.name for c in TagSuggestionRejection.__table__.primary_key.columns} pk_cols = {c.name for c in TagSuggestionRejection.__table__.primary_key.columns}
assert pk_cols == {"image_record_id", "tag_id"} assert pk_cols == {"image_record_id", "tag_id"}
-182
View File
@@ -1,182 +0,0 @@
import pytest
from sqlalchemy import select
from backend.app.models import (
ImagePrediction,
TagAlias,
TagAllowlist,
TagKind,
TagSuggestionRejection,
)
from backend.app.models.tag import image_tag
from backend.app.services.ml.allowlist import AllowlistService
from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration
async def _make_image(db, sha: str = "x" * 64):
from backend.app.models import ImageRecord
img = ImageRecord(
# Full sha in the path — the first 8 chars collide for sequential
# shas like c{i:063d}, and path is UNIQUE (uq_image_record_path).
path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1,
mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
)
db.add(img)
await db.flush()
return img
async def _add_pred(db, image_id, raw_name, score, category="general"):
db.add(ImagePrediction(
image_record_id=image_id, raw_name=raw_name,
category=category, score=score,
))
await db.flush()
@pytest.mark.asyncio
async def test_accept_applies_and_allowlists(db):
img = await _make_image(db)
tag = await TagService(db).find_or_create("Hero", TagKind.character)
svc = AllowlistService(db)
newly_added = await svc.accept(img.id, tag.id)
assert newly_added is True
applied = (
await db.execute(
select(image_tag.c.source)
.where(image_tag.c.image_record_id == img.id)
.where(image_tag.c.tag_id == tag.id)
)
).scalar_one()
assert applied == "ml_accepted"
assert await db.get(TagAllowlist, tag.id) is not None
@pytest.mark.asyncio
async def test_accept_idempotent_allowlist(db):
img = await _make_image(db)
tag = await TagService(db).find_or_create("Hero2", TagKind.character)
svc = AllowlistService(db)
assert await svc.accept(img.id, tag.id) is True
assert await svc.accept(img.id, tag.id) is False
@pytest.mark.asyncio
async def test_reject_applied_tag_records_rejection(db):
img = await _make_image(db)
tag = await TagService(db).find_or_create("Removeme", TagKind.general)
svc = AllowlistService(db)
await svc.accept(img.id, tag.id)
await svc.reject_applied_tag(img.id, tag.id)
still_applied = (
await db.execute(
select(image_tag.c.tag_id)
.where(image_tag.c.image_record_id == img.id)
.where(image_tag.c.tag_id == tag.id)
)
).scalar_one_or_none()
assert still_applied is None
rej = await db.get(TagSuggestionRejection, (img.id, tag.id))
assert rej is not None
@pytest.mark.asyncio
async def test_dismiss_records_rejection(db):
img = await _make_image(db)
tag = await TagService(db).find_or_create("Dismissme", TagKind.general)
await AllowlistService(db).dismiss(img.id, tag.id)
assert await db.get(TagSuggestionRejection, (img.id, tag.id)) is not None
@pytest.mark.asyncio
async def test_add_alias_and_accept(db):
img = await _make_image(db)
canonical = await TagService(db).find_or_create(
"Canonical Char", TagKind.character
)
svc = AllowlistService(db)
await svc.add_alias_and_accept(
img.id, "model_char_name", "character", canonical.id
)
from backend.app.services.ml.aliases import AliasService
resolved = await AliasService(db).resolve("model_char_name", "character")
assert resolved.id == canonical.id
assert await db.get(TagAllowlist, canonical.id) is not None
@pytest.mark.asyncio
async def test_update_threshold_and_remove(db):
tag = await TagService(db).find_or_create("Thr", TagKind.general)
svc = AllowlistService(db)
img = await _make_image(db)
await svc.accept(img.id, tag.id)
await svc.update_threshold(tag.id, 0.80)
row = await db.get(TagAllowlist, tag.id)
assert abs(row.min_confidence - 0.80) < 1e-6
await svc.remove(tag.id)
assert await db.get(TagAllowlist, tag.id) is None
@pytest.mark.asyncio
async def test_coverage_by_threshold_direct_name(db):
tag = await TagService(db).find_or_create("Cov", TagKind.general)
svc = AllowlistService(db)
for i, score in enumerate((0.95, 0.80, 0.60)):
img = await _make_image(db, sha=f"c{i:063d}")
await _add_pred(db, img.id, "Cov", score)
assert await svc.coverage(tag.id, 0.90) == 1
assert await svc.coverage(tag.id, 0.70) == 2
assert await svc.coverage(tag.id, 0.50) == 3
@pytest.mark.asyncio
async def test_coverage_via_alias_respects_category(db):
tag = await TagService(db).find_or_create("Aliased", TagKind.character)
db.add(TagAlias(
alias_string="model_key", alias_category="character",
canonical_tag_id=tag.id,
))
await db.flush()
svc = AllowlistService(db)
hit = await _make_image(db, sha=f"a{0:063d}")
await _add_pred(db, hit.id, "model_key", 0.92, category="character")
# Same alias string but wrong category must NOT resolve to the tag.
miss = await _make_image(db, sha=f"a{1:063d}")
await _add_pred(db, miss.id, "model_key", 0.99, category="general")
assert await svc.coverage(tag.id, 0.90) == 1
@pytest.mark.asyncio
async def test_list_all_reports_applied_and_coverage(db):
tag = await TagService(db).find_or_create("Both", TagKind.general)
svc = AllowlistService(db)
applied_img = await _make_image(db, sha=f"b{0:063d}")
await svc.accept(applied_img.id, tag.id) # applies + allowlists
await _add_pred(db, applied_img.id, "Both", 0.95)
# A second image only has a qualifying prediction (covered, not applied).
cov_img = await _make_image(db, sha=f"b{1:063d}")
await _add_pred(db, cov_img.id, "Both", 0.95)
rows = await svc.list_all()
row = next(r for r in rows if r.tag_id == tag.id)
assert row.applied_count == 1 # only the accepted image
assert row.coverage_count == 2 # both have a ≥threshold pred
@pytest.mark.asyncio
async def test_update_threshold_clamped_to_store_floor(db):
# A min_confidence below the store floor (default 0.70) is clamped up —
# predictions below the floor aren't stored, so a lower threshold can't
# apply more permissively than the floor (#764).
tag = await TagService(db).find_or_create("Lowthr", TagKind.general)
svc = AllowlistService(db)
img = await _make_image(db)
await svc.accept(img.id, tag.id)
await svc.update_threshold(tag.id, 0.30)
row = await db.get(TagAllowlist, tag.id)
assert abs(row.min_confidence - 0.70) < 1e-6
-5
View File
@@ -3,11 +3,6 @@ import pytest
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
def test_artist_not_surfaced():
from backend.app.services.ml.tagger import SURFACED_CATEGORIES
assert "artist" not in SURFACED_CATEGORIES
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.
-54
View File
@@ -1,54 +0,0 @@
"""Tagger unit tests. The ONNX model isn't available in CI (it's a 1GB
download into /models), so these test the pure-logic surface:
DEFAULT_STORE_FLOOR constant, SURFACED_CATEGORIES set, TagPrediction
dataclass, and the load()-missing-file error path. Full inference is
exercised by the local integration suite against a real /models volume.
"""
import pytest
from backend.app.services.ml.tagger import (
DEFAULT_STORE_FLOOR,
SURFACED_CATEGORIES,
Tagger,
TagPrediction,
get_tagger,
)
def test_surfaced_categories():
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-
# derived (image_record.artist_id), never ML-inferred.
# 2026-06-01: 'copyright' retired — fandom serves as the franchise/
# copyright concept; operator doesn't use a separate copyright kind.
assert SURFACED_CATEGORIES == {"character", "general"}
assert "artist" not in SURFACED_CATEGORIES
assert "copyright" not in SURFACED_CATEGORIES
def test_default_store_floor():
# Raised 0.05 → 0.70 (plan-task #764): the suggestion path filters at
# 0.70 and the centroid path covers lower-confidence preferred tags, so
# storing the sub-0.70 tail was redundant (100 GB of TOAST). The live
# value is DB-backed (ml_settings.tagger_store_floor); this is the default.
assert DEFAULT_STORE_FLOOR == 0.70
def test_tag_prediction_dataclass():
p = TagPrediction(name="x", category="general", confidence=0.9)
assert p.name == "x"
assert p.category == "general"
assert p.confidence == 0.9
def test_get_tagger_singleton():
assert get_tagger() is get_tagger()
def test_load_raises_when_model_missing(tmp_path):
t = Tagger(model_dir=tmp_path / "nonexistent")
# Match the trailing "missing at <path>" rather than the specific
# filename, so a future model-version bump (camie-tagger-v3.onnx, etc.)
# doesn't bounce this test.
with pytest.raises(RuntimeError, match=r"\.onnx missing at "):
t.load()
-11
View File
@@ -11,7 +11,6 @@ from PIL import Image
from sqlalchemy import func, select from sqlalchemy import func, select
from backend.app.models import ( from backend.app.models import (
ImagePrediction,
ImageProvenance, ImageProvenance,
ImageRecord, ImageRecord,
ImportSettings, ImportSettings,
@@ -119,11 +118,6 @@ def test_smaller_existing_is_superseded(importer, import_layout):
image_record_id=eid, tag_id=tag.id, source="manual" image_record_id=eid, tag_id=tag.id, source="manual"
) )
) )
importer.session.add(
ImagePrediction(
image_record_id=eid, raw_name="x", category="general", score=0.9
)
)
old.siglip_embedding = [0.0] * 1152 old.siglip_embedding = [0.0] * 1152
old.integrity_status = "ok" old.integrity_status = "ok"
importer.session.commit() importer.session.commit()
@@ -141,11 +135,6 @@ def test_smaller_existing_is_superseded(importer, import_layout):
assert row.path != old_path assert row.path != old_path
assert row.phash is not None assert row.phash is not None
assert row.integrity_status == "unknown" assert row.integrity_status == "unknown"
# #768: re-import clears the normalized predictions too
assert importer.session.execute(
select(func.count()).select_from(ImagePrediction)
.where(ImagePrediction.image_record_id == eid)
).scalar_one() == 0
assert row.siglip_embedding is None assert row.siglip_embedding is None
linked = importer.session.execute( linked = importer.session.execute(
select(image_tag.c.tag_id).where( select(image_tag.c.tag_id).where(
+3 -43
View File
@@ -2,7 +2,6 @@ import pytest
from sqlalchemy import func, select from sqlalchemy import func, select
from backend.app.models import Tag, TagKind, image_tag from backend.app.models import Tag, TagKind, image_tag
from backend.app.models.tag_allowlist import TagAllowlist
from backend.app.services.tag_service import ( from backend.app.services.tag_service import (
MergeResult, MergeResult,
TagMergeConflict, TagMergeConflict,
@@ -110,18 +109,6 @@ async def test_will_alias_true_when_machine_sourced(db):
assert ei.value.will_alias is True assert ei.value.will_alias is True
@pytest.mark.asyncio
async def test_will_alias_true_when_allowlisted(db):
svc = TagService(db)
await svc.find_or_create("Canon2", TagKind.general)
source = await svc.find_or_create("Allowed", TagKind.general)
db.add(TagAllowlist(tag_id=source.id))
await db.flush()
with pytest.raises(TagMergeConflict) as ei:
await svc.rename(source.id, "Canon2")
assert ei.value.will_alias is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_merge_rejects_self_merge(db): async def test_merge_rejects_self_merge(db):
svc = TagService(db) svc = TagService(db)
@@ -250,35 +237,6 @@ async def test_merge_dedups_suggestion_rejections(db):
).first() is None ).first() is None
@pytest.mark.asyncio
async def test_merge_allowlist_target_has_keeps_target_threshold(db):
svc = TagService(db)
a = await svc.find_or_create("SrcAL", TagKind.general)
b = await svc.find_or_create("TgtAL", TagKind.general)
db.add(TagAllowlist(tag_id=a.id, min_confidence=0.5))
db.add(TagAllowlist(tag_id=b.id, min_confidence=0.9))
await db.flush()
await svc.merge(a.id, b.id)
rows = (await db.execute(select(TagAllowlist))).scalars().all()
assert len(rows) == 1
assert rows[0].tag_id == b.id
assert rows[0].min_confidence == 0.9
@pytest.mark.asyncio
async def test_merge_allowlist_source_only_moves_to_target(db):
svc = TagService(db)
a = await svc.find_or_create("SrcAL2", TagKind.general)
b = await svc.find_or_create("TgtAL2", TagKind.general)
db.add(TagAllowlist(tag_id=a.id, min_confidence=0.42))
await db.flush()
await svc.merge(a.id, b.id)
rows = (await db.execute(select(TagAllowlist))).scalars().all()
assert len(rows) == 1
assert rows[0].tag_id == b.id
assert rows[0].min_confidence == 0.42
@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
@@ -372,7 +330,9 @@ async def test_alias_fallback_to_kind_when_no_predictions(db):
svc = TagService(db) svc = TagService(db)
a = await svc.find_or_create("AllowNoPred", TagKind.character) a = await svc.find_or_create("AllowNoPred", TagKind.character)
b = await svc.find_or_create("CanonF", TagKind.character) b = await svc.find_or_create("CanonF", TagKind.character)
db.add(TagAllowlist(tag_id=a.id)) # Machine-known via a prior accept (source='ml_accepted') → kept as alias.
img = await _img(db)
await svc.add_to_image(img, a.id, source="ml_accepted")
await db.flush() await db.flush()
result = await svc.merge(a.id, b.id) result = await svc.merge(a.id, b.id)
assert result.alias_created is True assert result.alias_created is True
+4 -107
View File
@@ -1,15 +1,12 @@
"""tag_and_embed / backfill task tests. Models aren't in CI, so we test """tag_and_embed (embed-only) / backfill task tests. The pure _is_video helper
the pure helpers (_aggregate_video_predictions, _is_video) as unit tests, and is a unit test; the DB-touching backfill query is an integration test with
the DB-touching backfill query as an integration test with monkeypatched monkeypatched dispatch."""
inference.
"""
from pathlib import Path from pathlib import Path
import pytest import pytest
from backend.app.services.ml.tagger import TagPrediction from backend.app.tasks.ml import _is_video
from backend.app.tasks.ml import _aggregate_video_predictions, _is_video
def test_is_video(): def test_is_video():
@@ -18,34 +15,6 @@ def test_is_video():
assert _is_video(Path("a.jpg")) is False assert _is_video(Path("a.jpg")) is False
def _pred(name, conf, cat="general"):
return {name: TagPrediction(name, cat, conf)}
def test_aggregate_video_keeps_corroborated_and_means():
# #747: 4 frames; "smile" in 3, "sword" in 1 (noise). min_frames=2.
per_frame = [
{"smile": TagPrediction("smile", "general", 0.6),
"sword": TagPrediction("sword", "general", 0.9)},
_pred("smile", 0.8),
_pred("smile", 0.7),
{},
]
out = _aggregate_video_predictions(per_frame, min_frames=2)
assert "sword" not in out # one-frame flicker dropped
assert abs(out["smile"]["confidence"] - (0.6 + 0.8 + 0.7) / 3) < 1e-9 # mean, not max
def test_aggregate_video_clamps_min_frames_to_sample_count():
# Short video: 1 frame but min_frames=3 — clamp so it still tags.
out = _aggregate_video_predictions([_pred("solo", 0.8)], min_frames=3)
assert out["solo"]["confidence"] == 0.8
def test_aggregate_video_empty():
assert _aggregate_video_predictions([], min_frames=3) == {}
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_backfill_enqueues_missing(db, monkeypatch): async def test_backfill_enqueues_missing(db, monkeypatch):
@@ -69,75 +38,3 @@ async def test_backfill_enqueues_missing(db, monkeypatch):
count = ml_tasks.backfill() count = ml_tasks.backfill()
assert count >= 1 assert count >= 1
assert img.id in calls assert img.id in calls
@pytest.mark.integration
@pytest.mark.asyncio
async def test_apply_allowlist_applies_above_threshold(db):
from sqlalchemy import select
from backend.app.models import ImageRecord, TagAllowlist, TagKind
from backend.app.models.tag import image_tag
from backend.app.services.tag_service import TagService
from backend.app.tasks import ml as ml_tasks
from tests._prediction_helpers import seed_predictions
tag = await TagService(db).find_or_create("autohero", TagKind.character)
db.add(TagAllowlist(tag_id=tag.id, min_confidence=0.95))
img = ImageRecord(
path="/images/al.jpg", sha256="al" + "0" * 62, size_bytes=1,
mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
)
db.add(img)
await db.commit()
await seed_predictions(
db, img.id, {"autohero": {"category": "character", "confidence": 0.97}}
)
await db.commit()
n = ml_tasks.apply_allowlist_tags(tag_id=tag.id)
assert n >= 1
src = (
await db.execute(
select(image_tag.c.source)
.where(image_tag.c.image_record_id == img.id)
.where(image_tag.c.tag_id == tag.id)
)
).scalar_one()
assert src == "ml_auto"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_apply_allowlist_skips_below_threshold(db):
from sqlalchemy import select
from backend.app.models import ImageRecord, TagAllowlist, TagKind
from backend.app.models.tag import image_tag
from backend.app.services.tag_service import TagService
from backend.app.tasks import ml as ml_tasks
from tests._prediction_helpers import seed_predictions
tag = await TagService(db).find_or_create("lowconf", TagKind.character)
db.add(TagAllowlist(tag_id=tag.id, min_confidence=0.95))
img = ImageRecord(
path="/images/lc.jpg", sha256="lc" + "0" * 62, size_bytes=1,
mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
)
db.add(img)
await db.commit()
await seed_predictions(
db, img.id, {"lowconf": {"category": "character", "confidence": 0.40}}
)
await db.commit()
ml_tasks.apply_allowlist_tags(tag_id=tag.id)
applied = (
await db.execute(
select(image_tag.c.tag_id)
.where(image_tag.c.image_record_id == img.id)
.where(image_tag.c.tag_id == tag.id)
)
).scalar_one_or_none()
assert applied is None