feat(ml): schedule presentation auto-hide sweep + retention (#141 step 6)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m41s

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:
2026-07-06 23:46:09 -04:00
parent 9726d6f4b5
commit 2bcaa20b22
3 changed files with 147 additions and 0 deletions
+9
View File
@@ -152,6 +152,15 @@ def make_celery() -> Celery:
"schedule": 86400.0, # soft auto-apply: drop auto-tags now below "schedule": 86400.0, # soft auto-apply: drop auto-tags now below
# their threshold (m139); no-op unless the auto-apply switch is on # 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": { "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,
+40
View File
@@ -594,6 +594,46 @@ def scheduled_ccip_auto_apply() -> str:
return f"applied={applied}" 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( @celery.task(
name="backend.app.tasks.ml.scheduled_retract_auto_tags", name="backend.app.tasks.ml.scheduled_retract_auto_tags",
soft_time_limit=1800, time_limit=2100, soft_time_limit=1800, time_limit=2100,
+98
View File
@@ -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}