feat(ml): soft auto-apply — retract auto-tags now below threshold (milestone 139)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Failing after 3m41s

Daily scheduled_retract_auto_tags re-scores standing auto-applied tags and drops
the ones the model no longer supports:
- retract_auto_applied_heads: per graduated head, re-score its source='head_auto'
  images (bounded — only the images already carrying the auto-tag, not the whole
  library) and remove ones now < auto_apply_threshold.
- retract_auto_applied_ccip: per source='ccip_auto' character tag, max-cosine the
  image's figure vectors vs that character's prototypes; remove ones now below the
  ccip auto-apply threshold.
Both SKIP operator-confirmed tags (TagPositiveConfirmation) and are 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 the relevant auto-apply switch is
on. New daily beat. sklearn-free tests for both paths + the disabled no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-06 18:13:37 -04:00
parent cbc3e11a53
commit 3006e84cc0
5 changed files with 320 additions and 1 deletions
+5
View File
@@ -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,
@@ -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
+62
View File
@@ -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
+20
View File
@@ -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}"