diff --git a/alembic/versions/0081_stricter_auto_apply_defaults.py b/alembic/versions/0081_stricter_auto_apply_defaults.py new file mode 100644 index 0000000..8030eec --- /dev/null +++ b/alembic/versions/0081_stricter_auto_apply_defaults.py @@ -0,0 +1,43 @@ +"""stricter auto-apply defaults (milestone 139) — cut auto-apply misfires + +head_auto_apply_min_positives 30→50 and ccip_auto_apply_threshold 0.92→0.95 +(operator-asked 2026-07-06). The head graduation precision bar stays 0.97 — the +operator confirmed the general-tag confidence was already well tuned; only the +support floor + the CCIP match confidence are raised. The model defaults change +for fresh installs; here we bump the existing singleton row IFF it is still at +the previous default, so a deliberate operator change is NOT clobbered. + +Revision ID: 0081 +Revises: 0080 +Create Date: 2026-07-06 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0081" +down_revision: Union[str, None] = "0080" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "UPDATE ml_settings SET head_auto_apply_min_positives = 50 " + "WHERE head_auto_apply_min_positives = 30" + ) + op.execute( + "UPDATE ml_settings SET ccip_auto_apply_threshold = 0.95 " + "WHERE ccip_auto_apply_threshold = 0.92" + ) + + +def downgrade() -> None: + op.execute( + "UPDATE ml_settings SET head_auto_apply_min_positives = 30 " + "WHERE head_auto_apply_min_positives = 50" + ) + op.execute( + "UPDATE ml_settings SET ccip_auto_apply_threshold = 0.92 " + "WHERE ccip_auto_apply_threshold = 0.95" + ) diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index f42db3b..ed6e9a8 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -147,6 +147,11 @@ def make_celery() -> Celery: "task": "backend.app.tasks.ml.scheduled_ccip_auto_apply", "schedule": 86400.0, # no-op unless ccip_auto_apply_enabled }, + "retract-auto-tags-daily": { + "task": "backend.app.tasks.ml.scheduled_retract_auto_tags", + "schedule": 86400.0, # soft auto-apply: drop auto-tags now below + # their threshold (m139); no-op unless the auto-apply switch is on + }, "snapshot-head-metrics-daily": { "task": "backend.app.tasks.maintenance.snapshot_head_metrics", "schedule": 86400.0, diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py index c03075e..80f81f9 100644 --- a/backend/app/models/ml_settings.py +++ b/backend/app/models/ml_settings.py @@ -63,7 +63,9 @@ class MLSettings(Base): Boolean, nullable=False, default=True ) head_auto_apply_min_positives: Mapped[int] = mapped_column( - Integer, nullable=False, default=30 + # Support floor raised 30→50 (operator-asked 2026-07-06): a head needs + # more human labels before it may fire without a human. + Integer, nullable=False, default=50 ) # CCIP character-match cosine cut (#114). 0.85 default — the v1 flat 0.75 # over-fired (high-reference characters matched a scatter of images); 0.85 @@ -78,7 +80,9 @@ class MLSettings(Base): Boolean, nullable=False, default=True ) ccip_auto_apply_threshold: Mapped[float] = mapped_column( - Float, nullable=False, default=0.92 + # Raised 0.92→0.95 (operator-asked 2026-07-06) so only very confident + # character matches auto-tag. + Float, nullable=False, default=0.95 ) # Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069); # existing libraries keep their stored value until the operator re-embeds. diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index 4cde351..94e949f 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -22,7 +22,15 @@ from sqlalchemy import Select, and_, distinct, exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import aliased -from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag +from ..models import ( + Artist, + ImageProvenance, + ImageRecord, + Post, + Source, + Tag, + TagPositiveConfirmation, +) from ..models.tag import PRESENTATION_SYSTEM_TAGS, image_tag from .pagination import decode_cursor, encode_cursor from .tag_query import ( @@ -731,8 +739,14 @@ class GalleryService: # Self-join Tag to resolve a character's fandom NAME (not just id) so the # modal chip can label it without an N+1 (shared tag_query helpers). fandom_alias = fandom_join_alias() + # source drives the auto-applied badge; confirmed = operator affirmed the + # tag (positive + retraction-shielded, milestone 139). + confirmed = exists().where( + TagPositiveConfirmation.image_record_id == image_id, + TagPositiveConfirmation.tag_id == Tag.id, + ).label("confirmed") tag_stmt = ( - select(*tag_columns(fandom_alias)) + select(*tag_columns(fandom_alias), image_tag.c.source, confirmed) .select_from( Tag.__table__ .join(image_tag, image_tag.c.tag_id == Tag.id) diff --git a/backend/app/services/ml/ccip.py b/backend/app/services/ml/ccip.py index bf09a09..833b55c 100644 --- a/backend/app/services/ml/ccip.py +++ b/backend/app/services/ml/ccip.py @@ -13,7 +13,7 @@ exact CCIP difference metric/threshold gets validated against the model during the hands-on eval. numpy is imported lazily (API worker has it via pgvector). """ -from sqlalchemy import func, select +from sqlalchemy import exists, func, select from sqlalchemy.ext.asyncio import AsyncSession from ...models import ( @@ -23,8 +23,10 @@ from ...models import ( MLSettings, Tag, TagKind, + TagPositiveConfirmation, ) from ...models.tag import image_tag +from .training_data import _AUTO_SOURCES # Cosine-similarity floor to call a figure the same character. The live setting # (ml_settings.ccip_match_threshold) drives it; this is only the fallback when no @@ -111,6 +113,17 @@ async def _ref_signature(session: AsyncSession) -> tuple: return (n_tags, n_regs, max_id, n_hygiene) +def _positive_char_tag(): + """Condition on the joined character image_tag: HUMAN-applied or operator- + confirmed — NOT an unconfirmed auto-apply. Keeps an auto-tagged character from + self-seeding CCIP references, so a ccip_auto misfire can't reinforce itself + (milestone 139) — mirrors the head-training positive exclusion.""" + return image_tag.c.source.not_in(_AUTO_SOURCES) | exists().where( + TagPositiveConfirmation.image_record_id == image_tag.c.image_record_id, + TagPositiveConfirmation.tag_id == image_tag.c.tag_id, + ) + + async def character_references(session: AsyncSession) -> dict[int, list]: """Per character-tag CCIP reference vectors: figure/face-region CCIP embeddings on UNAMBIGUOUS (single-character) images carrying that tag. @@ -128,6 +141,7 @@ async def character_references(session: AsyncSession) -> dict[int, list]: ) .join(Tag, Tag.id == image_tag.c.tag_id) .where(Tag.kind == TagKind.character) + .where(_positive_char_tag()) .where(ImageRegion.kind.in_(_FIGURE_KINDS)) .where(ImageRegion.ccip_embedding.is_not(None)) .where(ImageRegion.image_record_id.in_(_single_character_images())) diff --git a/backend/app/services/ml/character_prototypes.py b/backend/app/services/ml/character_prototypes.py index 8f8fb72..7a4802a 100644 --- a/backend/app/services/ml/character_prototypes.py +++ b/backend/app/services/ml/character_prototypes.py @@ -31,9 +31,16 @@ from ...models import ( MLSettings, Tag, TagKind, + TagPositiveConfirmation, ) from ...models.tag import image_tag -from .ccip import _FIGURE_KINDS, _hygiene_tagged_images, _single_character_images +from .ccip import ( + _FIGURE_KINDS, + _hygiene_tagged_images, + _l2norm, + _positive_char_tag, + _single_character_images, +) # Deterministic per-tag capping so a rebuild of an UNCHANGED reference set # resamples identically (stable prototypes, no churn between refreshes). @@ -63,7 +70,16 @@ def _global_signature(session: Session) -> str: .join(Tag, Tag.id == image_tag.c.tag_id) .where(Tag.is_system.is_(True)) ).scalar_one() - return f"{n_tags}:{n_regs}:{max_id or 0}:{n_hygiene}" + # Character confirmations affect the reference set now that auto-tags only + # seed references once confirmed (milestone 139) — so a confirm must trip the + # gate, or the per-character diff (which reflects it) never runs. + n_conf = session.execute( + select(func.count()) + .select_from(TagPositiveConfirmation) + .join(Tag, Tag.id == TagPositiveConfirmation.tag_id) + .where(Tag.kind == TagKind.character) + ).scalar_one() + return f"{n_tags}:{n_regs}:{max_id or 0}:{n_hygiene}:{n_conf}" def _current_fingerprints(session: Session) -> dict[int, str]: @@ -84,6 +100,7 @@ def _current_fingerprints(session: Session) -> dict[int, str]: ) .join(Tag, Tag.id == image_tag.c.tag_id) .where(Tag.kind == TagKind.character) + .where(_positive_char_tag()) .where(ImageRegion.kind.in_(_FIGURE_KINDS)) .where(ImageRegion.ccip_embedding.is_not(None)) .where(ImageRegion.image_record_id.in_(_single_character_images())) @@ -104,6 +121,7 @@ def _rebuild_one(session: Session, tag_id: int, cap: int) -> int: image_tag.c.image_record_id == ImageRegion.image_record_id, ) .where(image_tag.c.tag_id == tag_id) + .where(_positive_char_tag()) .where(ImageRegion.kind.in_(_FIGURE_KINDS)) .where(ImageRegion.ccip_embedding.is_not(None)) .where(ImageRegion.image_record_id.in_(_single_character_images())) @@ -173,3 +191,76 @@ def refresh_character_prototypes( settings.ccip_ref_signature = sig session.commit() return {"skipped": False, "rebuilt": rebuilt, "removed": removed} + + +def retract_auto_applied_ccip(session: Session) -> int: + """Soft auto-apply for CCIP character tags (milestone 139): re-score every + standing source='ccip_auto' character tag against that character's prototypes + and REMOVE the ones whose best figure match is now BELOW + ccip_auto_apply_threshold. Skips operator-confirmed tags. SILENT — a low score + isn't proof the tag was wrong (that's reserved for an operator removal). No-op + unless ccip_auto_apply_enabled. A character with no prototypes yet, or an image + with no figure vectors, is left alone (can't judge → keep). Returns + n_retracted.""" + import numpy as np + + settings = session.execute( + select(MLSettings).where(MLSettings.id == 1) + ).scalar_one() + if not settings.ccip_auto_apply_enabled: + return 0 + thr = float(settings.ccip_auto_apply_threshold) + pairs = session.execute( + select(image_tag.c.image_record_id, image_tag.c.tag_id) + .where(image_tag.c.source == "ccip_auto") + ).all() + if not pairs: + return 0 + confirmed = { + (iid, tid) for iid, tid in session.execute( + select( + TagPositiveConfirmation.image_record_id, + TagPositiveConfirmation.tag_id, + ) + ).all() + } + # Each involved character's normalized prototype matrix, loaded once. + proto: dict[int, object] = {} + for tid in {tid for _iid, tid in pairs}: + vecs = [ + v for (v,) in session.execute( + select(CharacterPrototype.ccip_embedding) + .where(CharacterPrototype.tag_id == tid) + ) + ] + if vecs: + proto[tid] = _l2norm( + np.vstack([np.asarray(v, dtype=np.float32) for v in vecs]), np + ) + retracted = 0 + for iid, tid in pairs: + if (iid, tid) in confirmed or tid not in proto: + continue # confirmed / no prototypes + qvecs = [ + v for (v,) in session.execute( + select(ImageRegion.ccip_embedding) + .where(ImageRegion.image_record_id == iid) + .where(ImageRegion.kind.in_(_FIGURE_KINDS)) + .where(ImageRegion.ccip_embedding.is_not(None)) + ) + ] + if not qvecs: + continue # no figure vectors → keep + Q = _l2norm( + np.vstack([np.asarray(v, dtype=np.float32) for v in qvecs]), np + ) + if float((Q @ proto[tid].T).max()) < thr: + session.execute( + image_tag.delete() + .where(image_tag.c.image_record_id == iid) + .where(image_tag.c.tag_id == tid) + .where(image_tag.c.source == "ccip_auto") + ) + retracted += 1 + session.commit() + return retracted diff --git a/backend/app/services/ml/heads.py b/backend/app/services/ml/heads.py index 41059cd..ae85b46 100644 --- a/backend/app/services/ml/heads.py +++ b/backend/app/services/ml/heads.py @@ -22,7 +22,7 @@ import logging from datetime import UTC, datetime from typing import Any -from sqlalchemy import delete, func, select +from sqlalchemy import delete, exists, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -35,10 +35,12 @@ from ...models import ( Tag, TagHead, TagKind, + TagPositiveConfirmation, TagSuggestionRejection, ) from ...models.tag import image_tag from .training_data import ( + _AUTO_SOURCES, _auto_apply_point, _hygiene_excluded_ids, _ids_with_tag, @@ -137,13 +139,20 @@ def _embedder_version(session: Session) -> str: def _eligible_tag_ids(session: Session, min_pos: int) -> list[int]: - """Concept tags (general/character) with >= min_pos labelled images — the - set that gets a head. Counts all sources; source-aware filtering (#1133) is - a separate, optional refinement.""" + """Concept tags (general/character) with >= min_pos POSITIVE images — the set + that gets a head. Counts human-applied + operator-confirmed tags only; + unconfirmed auto-applied predictions do NOT count toward eligibility (they + don't train the head — milestone 139), so a concept can't graduate on its own + guesses.""" + confirmed = exists().where( + TagPositiveConfirmation.image_record_id == image_tag.c.image_record_id, + TagPositiveConfirmation.tag_id == image_tag.c.tag_id, + ) rows = session.execute( select(Tag.id) .join(image_tag, image_tag.c.tag_id == Tag.id) .where(Tag.kind.in_(_HEAD_KINDS)) + .where(image_tag.c.source.not_in(_AUTO_SOURCES) | confirmed) .group_by(Tag.id) .having(func.count(image_tag.c.image_record_id) >= min_pos) ).all() @@ -180,11 +189,20 @@ def _head_fingerprints(session: Session, tag_ids: list[int]) -> dict[int, str]: .group_by(TagSuggestionRejection.tag_id) ).all() rej_map = {t: (c, m) for t, c, m in rej} + # Confirmations promote an auto-applied tag to a positive (milestone 139), so + # a confirm must move the fingerprint too — else a manual Retrain right after + # confirming wouldn't fold the tag in (the nightly full run would). + conf = session.execute( + select(TagPositiveConfirmation.tag_id, func.count()) + .where(TagPositiveConfirmation.tag_id.in_(tag_ids)) + .group_by(TagPositiveConfirmation.tag_id) + ).all() + conf_map = dict(conf) out = {} for t in tag_ids: pc, pm = pos_map.get(t, (0, None)) rc, rm = rej_map.get(t, (0, None)) - out[t] = f"{pc}:{pm}:{rc}:{rm}" + out[t] = f"{pc}:{pm}:{rc}:{rm}:{conf_map.get(t, 0)}" return out @@ -723,3 +741,64 @@ def auto_apply_sweep( for h in range(len(rows)) ] return {"n_applied": sum(applied), "concepts": concepts} + + +def retract_auto_applied_heads(session: Session) -> int: + """Soft auto-apply (milestone 139): re-score every standing source='head_auto' + tag against its CURRENT head and REMOVE the ones now BELOW the head's + auto_apply_threshold — i.e. the head sharpened (or the operator raised the bar) + and no longer supports them. Skips operator-confirmed tags + (TagPositiveConfirmation). SILENT: a low score isn't proof the tag was wrong, + so no hard negative is recorded — that's reserved for an operator removal. + No-op unless head_auto_apply_enabled. Only re-scores the images that ALREADY + carry the auto-tag (bounded), never the whole library. Returns n_retracted.""" + import numpy as np + + settings = _settings(session) + if not settings.head_auto_apply_enabled: + return 0 + heads = session.execute( + select( + TagHead.tag_id, TagHead.weights, TagHead.bias, + TagHead.auto_apply_threshold, + ) + .where(TagHead.embedding_version == settings.embedder_model_version) + .where(TagHead.auto_apply_threshold.is_not(None)) + ).all() + retracted = 0 + for tag_id, weights, bias, thr in heads: + auto_ids = [ + iid for (iid,) in session.execute( + select(image_tag.c.image_record_id) + .where(image_tag.c.tag_id == tag_id) + .where(image_tag.c.source == "head_auto") + ) + ] + if not auto_ids: + continue + confirmed = { + iid for (iid,) in session.execute( + select(TagPositiveConfirmation.image_record_id) + .where(TagPositiveConfirmation.tag_id == tag_id) + .where(TagPositiveConfirmation.image_record_id.in_(auto_ids)) + ) + } + candidates = [i for i in auto_ids if i not in confirmed] + emb = _load_embeddings(session, candidates) + cids = [i for i in candidates if i in emb] + if not cids: + continue + Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np) + w = np.asarray(weights, dtype=np.float32) + probs = 1.0 / (1.0 + np.exp(-(Xn @ w + float(bias)))) + below = [cids[k] for k in np.where(probs < float(thr))[0]] + for iid in below: + session.execute( + image_tag.delete() + .where(image_tag.c.image_record_id == iid) + .where(image_tag.c.tag_id == tag_id) + .where(image_tag.c.source == "head_auto") + ) + retracted += 1 + session.commit() + return retracted diff --git a/backend/app/services/ml/training_data.py b/backend/app/services/ml/training_data.py index 5e2963a..6c9802a 100644 --- a/backend/app/services/ml/training_data.py +++ b/backend/app/services/ml/training_data.py @@ -17,9 +17,20 @@ from typing import Any from sqlalchemy import func, select from sqlalchemy.orm import Session -from ...models import ImageRecord, Tag, TagSuggestionRejection +from ...models import ( + ImageRecord, + Tag, + TagPositiveConfirmation, + TagSuggestionRejection, +) from ...models.tag import image_tag +# Auto-apply sources whose tags are PROVISIONAL: they never train a head (or seed +# a CCIP reference) unless the operator confirms them (milestone 139). Keeping +# auto-applied predictions out of training is what makes them "soft" — a misfire +# can't reinforce itself, so the retraction sweep can actually drop it. +_AUTO_SOURCES = ("head_auto", "ccip_auto", "ml_auto") + def _hygiene_excluded_ids(session: Session) -> set[int]: """Ids of images carrying ANY system tag (wip / banner / editor @@ -45,9 +56,23 @@ def _hygiene_excluded_ids(session: Session) -> set[int]: def _ids_with_tag(session: Session, tag_id: int) -> list[int]: + """Image ids that count as POSITIVES for this tag's head: human-applied + (manual / accepted) tags PLUS any auto-applied tag the operator explicitly + confirmed (TagPositiveConfirmation). Unconfirmed auto-applied tags are + EXCLUDED — they are provisional and must not train the head that judges + them (milestone 139).""" + confirmed = ( + select(TagPositiveConfirmation.image_record_id) + .where(TagPositiveConfirmation.tag_id == tag_id) + ) return [ r[0] for r in session.execute( - select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tag_id) + select(image_tag.c.image_record_id) + .where(image_tag.c.tag_id == tag_id) + .where( + image_tag.c.source.not_in(_AUTO_SOURCES) + | image_tag.c.image_record_id.in_(confirmed) + ) ).all() ] diff --git a/backend/app/services/tag_query.py b/backend/app/services/tag_query.py index 13186dc..a60340e 100644 --- a/backend/app/services/tag_query.py +++ b/backend/app/services/tag_query.py @@ -91,4 +91,11 @@ def serialize_tag(row) -> dict: "fandom_id": row.fandom_id, "fandom_name": row.fandom_name, "is_system": bool(getattr(row, "is_system", False)), + # Applied-tag context: only the image-scoped selects (list_for_image / + # get_image_with_tags) provide these; autocomplete / directory don't → + # default. `source` drives the auto-applied badge; `confirmed` = the + # operator affirmed the tag (a training positive, shielded from the + # retraction sweep — milestone 139). + "source": getattr(row, "source", None), + "confirmed": bool(getattr(row, "confirmed", False)), } diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index 98e1d9c..b6fe1d6 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -9,7 +9,14 @@ from sqlalchemy import and_, case, exists, func, select, text, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession -from ..models import HeadMetric, Tag, TagHead, TagKind, image_tag +from ..models import ( + HeadMetric, + Tag, + TagHead, + TagKind, + TagPositiveConfirmation, + image_tag, +) from .db_helpers import get_or_create from .tag_query import fandom_join_alias, tag_columns @@ -288,8 +295,14 @@ class TagService: character chip with its fandom without an N+1 (mirrors the autocomplete/directory resolution).""" fandom_alias = fandom_join_alias() + # source drives the auto-applied badge; confirmed = operator affirmed the + # tag (positive + retraction-shielded, milestone 139). + confirmed = exists().where( + TagPositiveConfirmation.image_record_id == image_id, + TagPositiveConfirmation.tag_id == Tag.id, + ).label("confirmed") stmt = ( - select(*tag_columns(fandom_alias)) + select(*tag_columns(fandom_alias), image_tag.c.source, confirmed) .select_from( Tag.__table__ .join(image_tag, image_tag.c.tag_id == Tag.id) diff --git a/backend/app/tasks/ml.py b/backend/app/tasks/ml.py index 4959099..2172e71 100644 --- a/backend/app/tasks/ml.py +++ b/backend/app/tasks/ml.py @@ -592,3 +592,23 @@ def scheduled_ccip_auto_apply() -> str: applied += 1 session.commit() return f"applied={applied}" + + +@celery.task( + name="backend.app.tasks.ml.scheduled_retract_auto_tags", + soft_time_limit=1800, time_limit=2100, +) +def scheduled_retract_auto_tags() -> str: + """Soft auto-apply (milestone 139): retract standing head_auto/ccip_auto tags + the model no longer supports (score now below the auto-apply threshold), + skipping operator-confirmed ones. Silent (no hard negative). No-op unless the + respective auto-apply switch is on. Returns 'head=N ccip=M'.""" + from ..services.ml.character_prototypes import retract_auto_applied_ccip + from ..services.ml.heads import retract_auto_applied_heads + + SessionLocal = _sync_session_factory() + with SessionLocal() as session: + n_head = retract_auto_applied_heads(session) + with SessionLocal() as session: + n_ccip = retract_auto_applied_ccip(session) + return f"head={n_head} ccip={n_ccip}" diff --git a/frontend/src/components/modal/SuggestionsCategoryGroup.vue b/frontend/src/components/modal/SuggestionsCategoryGroup.vue index 4a317f6..613af70 100644 --- a/frontend/src/components/modal/SuggestionsCategoryGroup.vue +++ b/frontend/src/components/modal/SuggestionsCategoryGroup.vue @@ -1,14 +1,25 @@