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/services/ml/character_prototypes.py b/backend/app/services/ml/character_prototypes.py index 8f8fb72..602d4ec 100644 --- a/backend/app/services/ml/character_prototypes.py +++ b/backend/app/services/ml/character_prototypes.py @@ -31,9 +31,15 @@ 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, + _single_character_images, +) # Deterministic per-tag capping so a rebuild of an UNCHANGED reference set # resamples identically (stable prototypes, no churn between refreshes). @@ -173,3 +179,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..c01fd7f 100644 --- a/backend/app/services/ml/heads.py +++ b/backend/app/services/ml/heads.py @@ -35,6 +35,7 @@ from ...models import ( Tag, TagHead, TagKind, + TagPositiveConfirmation, TagSuggestionRejection, ) from ...models.tag import image_tag @@ -723,3 +724,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/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/tests/test_retract_auto_apply.py b/tests/test_retract_auto_apply.py new file mode 100644 index 0000000..1f93a91 --- /dev/null +++ b/tests/test_retract_auto_apply.py @@ -0,0 +1,153 @@ +"""Soft auto-apply (milestone 139): the retraction sweeps drop standing +head_auto/ccip_auto tags now below their threshold, keep the ones still above, +and never touch manual or operator-confirmed tags. Sync + sklearn-free (they +score with STORED weights/vectors), so tested directly via db_sync.""" +import pytest +from sqlalchemy import select + +from backend.app.models import ( + CharacterPrototype, + ImageRecord, + ImageRegion, + MLSettings, + Tag, + TagHead, + TagKind, + TagPositiveConfirmation, +) +from backend.app.models.tag import image_tag +from backend.app.services.ml.character_prototypes import retract_auto_applied_ccip +from backend.app.services.ml.heads import retract_auto_applied_heads + +pytestmark = pytest.mark.integration + + +def _emb(slot: int) -> list[float]: + v = [0.0] * 1152 + v[slot] = 3.0 + return v + + +def _ccip(slot: int) -> list[float]: + v = [0.0] * 768 + v[slot] = 1.0 + return v + + +def _img(db, sha: str, emb=None) -> ImageRecord: + img = ImageRecord( + path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg", + width=1, height=1, origin="imported_filesystem", + integrity_status="unknown", siglip_embedding=emb, + ) + db.add(img) + db.flush() + return img + + +def _figure(db, image_id: int, ccip) -> None: + db.add(ImageRegion( + image_record_id=image_id, kind="figure", + rx=0.0, ry=0.0, rw=1.0, rh=1.0, + ccip_embedding=ccip, embedding_version="ccip-test", + )) + db.flush() + + +def _tag(db, name: str, kind: TagKind) -> Tag: + t = Tag(name=name, kind=kind) + db.add(t) + db.flush() + return t + + +def _apply(db, image_id: int, tag_id: int, source: str) -> None: + db.execute(image_tag.insert().values( + image_record_id=image_id, tag_id=tag_id, source=source, + )) + + +def _version(db) -> str: + return db.execute( + select(MLSettings.embedder_model_version).where(MLSettings.id == 1) + ).scalar_one() + + +def _head(db, tag_id: int, slot: int, threshold: float, version: str) -> None: + w = [0.0] * 1152 + w[slot] = 1.0 + db.add(TagHead( + tag_id=tag_id, embedding_version=version, weights=w, bias=0.0, + suggest_threshold=0.5, auto_apply_threshold=threshold, + n_pos=60, n_neg=180, ap=0.9, precision_cv=0.98, recall=0.7, + )) + db.flush() + + +def _has_tag(db, image_id: int, tag_id: int) -> bool: + return db.execute( + select(image_tag.c.tag_id) + .where(image_tag.c.image_record_id == image_id) + .where(image_tag.c.tag_id == tag_id) + ).first() is not None + + +def test_retract_head_auto(db_sync): + ver = _version(db_sync) + tag = _tag(db_sync, "glasses", TagKind.general) + _head(db_sync, tag.id, slot=0, threshold=0.7, version=ver) + hi = _img(db_sync, "a" * 64, _emb(0)) # aligned → ~0.73 ≥ 0.7 → keep + lo = _img(db_sync, "b" * 64, _emb(5)) # orthogonal → 0.5 < 0.7 → retract + man = _img(db_sync, "c" * 64, _emb(5)) # low score but manual → keep + conf = _img(db_sync, "d" * 64, _emb(5)) # low score, head_auto, CONFIRMED → keep + _apply(db_sync, hi.id, tag.id, "head_auto") + _apply(db_sync, lo.id, tag.id, "head_auto") + _apply(db_sync, man.id, tag.id, "manual") + _apply(db_sync, conf.id, tag.id, "head_auto") + db_sync.add(TagPositiveConfirmation(image_record_id=conf.id, tag_id=tag.id)) + db_sync.commit() + + assert retract_auto_applied_heads(db_sync) == 1 + assert not _has_tag(db_sync, lo.id, tag.id) # retracted (below threshold) + assert _has_tag(db_sync, hi.id, tag.id) # kept (still above) + assert _has_tag(db_sync, man.id, tag.id) # kept (manual, not auto) + assert _has_tag(db_sync, conf.id, tag.id) # kept (operator-confirmed) + + +def test_retract_head_auto_noop_when_disabled(db_sync): + s = db_sync.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one() + s.head_auto_apply_enabled = False + ver = _version(db_sync) + tag = _tag(db_sync, "glasses", TagKind.general) + _head(db_sync, tag.id, slot=0, threshold=0.7, version=ver) + lo = _img(db_sync, "e" * 64, _emb(5)) # would be below threshold + _apply(db_sync, lo.id, tag.id, "head_auto") + db_sync.commit() + + assert retract_auto_applied_heads(db_sync) == 0 + assert _has_tag(db_sync, lo.id, tag.id) # switch off → nothing retracted + + +def test_retract_ccip_auto(db_sync): + char = _tag(db_sync, "Raven", TagKind.character) + db_sync.add(CharacterPrototype(tag_id=char.id, ccip_embedding=_ccip(0))) + hi = _img(db_sync, "f" * 64) # figure matches prototype → keep + lo = _img(db_sync, "g" * 64) # figure orthogonal → retract + conf = _img(db_sync, "h" * 64) # orthogonal, CONFIRMED → keep + man = _img(db_sync, "i" * 64) # orthogonal, manual → keep + _figure(db_sync, hi.id, _ccip(0)) + _figure(db_sync, lo.id, _ccip(5)) + _figure(db_sync, conf.id, _ccip(5)) + _figure(db_sync, man.id, _ccip(5)) + _apply(db_sync, hi.id, char.id, "ccip_auto") + _apply(db_sync, lo.id, char.id, "ccip_auto") + _apply(db_sync, conf.id, char.id, "ccip_auto") + _apply(db_sync, man.id, char.id, "manual") + db_sync.add(TagPositiveConfirmation(image_record_id=conf.id, tag_id=char.id)) + db_sync.commit() + + assert retract_auto_applied_ccip(db_sync) == 1 + assert not _has_tag(db_sync, lo.id, char.id) # retracted (below threshold) + assert _has_tag(db_sync, hi.id, char.id) # kept (match ≥ threshold) + assert _has_tag(db_sync, conf.id, char.id) # kept (operator-confirmed) + assert _has_tag(db_sync, man.id, char.id) # kept (manual, not auto)