refactor(ml): retire the Camie tagger + allowlist bulk-apply (#1189)
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:
@@ -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"],
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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,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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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()
|
|
||||||
)
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -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 (1–2 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'
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
})
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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"}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user