feat(ml): soft auto-apply — retract auto-tags now below threshold (milestone 139)
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:
@@ -147,6 +147,11 @@ def make_celery() -> Celery:
|
|||||||
"task": "backend.app.tasks.ml.scheduled_ccip_auto_apply",
|
"task": "backend.app.tasks.ml.scheduled_ccip_auto_apply",
|
||||||
"schedule": 86400.0, # no-op unless ccip_auto_apply_enabled
|
"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": {
|
"snapshot-head-metrics-daily": {
|
||||||
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
|
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
|
||||||
"schedule": 86400.0,
|
"schedule": 86400.0,
|
||||||
|
|||||||
@@ -31,9 +31,15 @@ from ...models import (
|
|||||||
MLSettings,
|
MLSettings,
|
||||||
Tag,
|
Tag,
|
||||||
TagKind,
|
TagKind,
|
||||||
|
TagPositiveConfirmation,
|
||||||
)
|
)
|
||||||
from ...models.tag import image_tag
|
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
|
# Deterministic per-tag capping so a rebuild of an UNCHANGED reference set
|
||||||
# resamples identically (stable prototypes, no churn between refreshes).
|
# resamples identically (stable prototypes, no churn between refreshes).
|
||||||
@@ -173,3 +179,76 @@ def refresh_character_prototypes(
|
|||||||
settings.ccip_ref_signature = sig
|
settings.ccip_ref_signature = sig
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"skipped": False, "rebuilt": rebuilt, "removed": removed}
|
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
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from ...models import (
|
|||||||
Tag,
|
Tag,
|
||||||
TagHead,
|
TagHead,
|
||||||
TagKind,
|
TagKind,
|
||||||
|
TagPositiveConfirmation,
|
||||||
TagSuggestionRejection,
|
TagSuggestionRejection,
|
||||||
)
|
)
|
||||||
from ...models.tag import image_tag
|
from ...models.tag import image_tag
|
||||||
@@ -723,3 +724,64 @@ def auto_apply_sweep(
|
|||||||
for h in range(len(rows))
|
for h in range(len(rows))
|
||||||
]
|
]
|
||||||
return {"n_applied": sum(applied), "concepts": concepts}
|
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
|
||||||
|
|||||||
@@ -592,3 +592,23 @@ def scheduled_ccip_auto_apply() -> str:
|
|||||||
applied += 1
|
applied += 1
|
||||||
session.commit()
|
session.commit()
|
||||||
return f"applied={applied}"
|
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}"
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user