feat(ml): schedule presentation auto-hide sweep + retention (#141 step 6)
scheduled_presentation_auto_apply (daily beat) runs presentation_auto_apply_sweep — idempotent, so an interrupted run just re-runs next cycle (that's the recovery), wall-clock bounded by soft/hard task time limits. prune_presentation_reviews (daily beat) drops RESOLVED review flags older than 30 days (rule 89 retention). Tests run both tasks via a monkeypatched session factory. Milestone 141 complete: the presentation-chrome auto-hide + conflict-flagged review is now live end-to-end. 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:
@@ -152,6 +152,15 @@ def make_celery() -> Celery:
|
||||
"schedule": 86400.0, # soft auto-apply: drop auto-tags now below
|
||||
# their threshold (m139); no-op unless the auto-apply switch is on
|
||||
},
|
||||
"presentation-auto-apply-daily": {
|
||||
"task": "backend.app.tasks.ml.scheduled_presentation_auto_apply",
|
||||
"schedule": 86400.0, # auto-hide banner/editor chrome (#141);
|
||||
# no-op unless presentation_auto_apply_enabled
|
||||
},
|
||||
"prune-presentation-reviews-daily": {
|
||||
"task": "backend.app.tasks.ml.prune_presentation_reviews",
|
||||
"schedule": 86400.0, # retention: drop resolved review flags >30d
|
||||
},
|
||||
"snapshot-head-metrics-daily": {
|
||||
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
|
||||
"schedule": 86400.0,
|
||||
|
||||
@@ -594,6 +594,46 @@ def scheduled_ccip_auto_apply() -> str:
|
||||
return f"applied={applied}"
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.ml.scheduled_presentation_auto_apply",
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def scheduled_presentation_auto_apply() -> str:
|
||||
"""Auto-hide presentation chrome (banner / editor screenshot) on a daily
|
||||
passive sweep (#141). No-op unless presentation_auto_apply_enabled. Idempotent
|
||||
— already-hidden images are skipped — so an interrupted run simply re-runs next
|
||||
cycle (that IS the recovery). Wall-clock bounded by the task time limits."""
|
||||
from ..services.ml.heads import presentation_auto_apply_sweep
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
result = presentation_auto_apply_sweep(session)
|
||||
return f"applied={result['n_applied']} flagged={result['n_flagged']}"
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.ml.prune_presentation_reviews")
|
||||
def prune_presentation_reviews() -> str:
|
||||
"""Retention (rule 89): drop RESOLVED presentation-review flags older than 30
|
||||
days — the operator has acted on them, so they're just history (#141)."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete as sa_delete
|
||||
|
||||
from ..models import PresentationReview
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(days=30)
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
res = session.execute(
|
||||
sa_delete(PresentationReview).where(
|
||||
PresentationReview.resolved_at.is_not(None),
|
||||
PresentationReview.resolved_at < cutoff,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return f"pruned={res.rowcount}"
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.ml.scheduled_retract_auto_tags",
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Scheduled presentation tasks (#141): the daily auto-hide sweep wrapper +
|
||||
resolved-flag retention. The task opens its own session via
|
||||
_sync_session_factory, monkeypatched here to run against the test's db_sync."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models import (
|
||||
ImageRecord,
|
||||
MLSettings,
|
||||
PresentationReview,
|
||||
Tag,
|
||||
TagHead,
|
||||
)
|
||||
from backend.app.tasks.ml import (
|
||||
prune_presentation_reviews,
|
||||
scheduled_presentation_auto_apply,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
class _Ctx:
|
||||
def __init__(self, s):
|
||||
self.s = s
|
||||
|
||||
def __enter__(self):
|
||||
return self.s
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
|
||||
def _sf(db_sync):
|
||||
class _SM:
|
||||
def __call__(self):
|
||||
return _Ctx(db_sync)
|
||||
|
||||
return _SM()
|
||||
|
||||
|
||||
def _img(db, sha, emb=None):
|
||||
img = ImageRecord(
|
||||
path=f"/images/{sha}.jpg", sha256=sha * 32, 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 test_scheduled_presentation_sweep_hides_chrome(db_sync, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.ml._sync_session_factory", lambda: _sf(db_sync)
|
||||
)
|
||||
banner = db_sync.execute(
|
||||
select(Tag).where(Tag.is_system.is_(True), Tag.name == "banner")
|
||||
).scalar_one()
|
||||
s = db_sync.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one()
|
||||
w = [0.0] * 1152
|
||||
w[0] = 3.0
|
||||
db_sync.add(TagHead(
|
||||
tag_id=banner.id, embedding_version=s.embedder_model_version,
|
||||
weights=w, bias=0.0, suggest_threshold=0.5, auto_apply_threshold=0.5,
|
||||
n_pos=60, n_neg=90, ap=0.9, precision_cv=0.98, recall=0.7,
|
||||
))
|
||||
emb = [0.0] * 1152
|
||||
emb[0] = 3.0
|
||||
_img(db_sync, "sc", emb)
|
||||
db_sync.commit()
|
||||
assert "applied=1" in scheduled_presentation_auto_apply()
|
||||
|
||||
|
||||
def test_prune_presentation_reviews_drops_old_resolved(db_sync, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.ml._sync_session_factory", lambda: _sf(db_sync)
|
||||
)
|
||||
banner = db_sync.execute(
|
||||
select(Tag).where(Tag.is_system.is_(True), Tag.name == "banner")
|
||||
).scalar_one()
|
||||
a, b, c = _img(db_sync, "p1"), _img(db_sync, "p2"), _img(db_sync, "p3")
|
||||
old = datetime.now(UTC) - timedelta(days=40)
|
||||
recent = datetime.now(UTC) - timedelta(days=1)
|
||||
# old resolved → pruned; recent resolved → kept; unresolved → kept.
|
||||
db_sync.add(PresentationReview(
|
||||
image_record_id=a.id, tag_id=banner.id, conflict_score=0.7, resolved_at=old))
|
||||
db_sync.add(PresentationReview(
|
||||
image_record_id=b.id, tag_id=banner.id, conflict_score=0.7, resolved_at=recent))
|
||||
db_sync.add(PresentationReview(
|
||||
image_record_id=c.id, tag_id=banner.id, conflict_score=0.7))
|
||||
db_sync.commit()
|
||||
assert prune_presentation_reviews() == "pruned=1"
|
||||
remaining = db_sync.execute(
|
||||
select(PresentationReview.image_record_id)
|
||||
).scalars().all()
|
||||
assert set(remaining) == {b.id, c.id}
|
||||
Reference in New Issue
Block a user