From 2bcaa20b2246ce8224bf1b14f3cc50f36afe6509 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 6 Jul 2026 23:46:09 -0400 Subject: [PATCH] feat(ml): schedule presentation auto-hide sweep + retention (#141 step 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM --- backend/app/celery_app.py | 9 +++ backend/app/tasks/ml.py | 40 ++++++++++++ tests/test_presentation_scheduled.py | 98 ++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 tests/test_presentation_scheduled.py diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index ed6e9a8..c8cd723 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -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, diff --git a/backend/app/tasks/ml.py b/backend/app/tasks/ml.py index 2172e71..0f6343a 100644 --- a/backend/app/tasks/ml.py +++ b/backend/app/tasks/ml.py @@ -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, diff --git a/tests/test_presentation_scheduled.py b/tests/test_presentation_scheduled.py new file mode 100644 index 0000000..7272b16 --- /dev/null +++ b/tests/test_presentation_scheduled.py @@ -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}