diff --git a/alembic/versions/0086_process_auto_apply_settings.py b/alembic/versions/0086_process_auto_apply_settings.py new file mode 100644 index 0000000..16f03c7 --- /dev/null +++ b/alembic/versions/0086_process_auto_apply_settings.py @@ -0,0 +1,61 @@ +"""process auto-apply settings + review mode (#1464) — system-tag refactor + +The system-tag behavior refactor gives `wip` / `editor screenshot` (the PROCESS +group) their own provisional auto-apply, parallel to the presentation (chrome) +sweep. MLSettings gains three knobs: enabled (OFF by default — a new whole-library +auto-tagger is opt-in), the flat apply threshold, and the ring-loud conflict +threshold. presentation_review gains a `mode` column so one review surface serves +both chrome and process flags (existing rows backfill 'chrome'). server_defaults +so the existing rows fill cleanly. + +Revision ID: 0086 +Revises: 0085 +Create Date: 2026-07-13 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0086" +down_revision: Union[str, None] = "0085" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "ml_settings", + sa.Column( + "process_auto_apply_enabled", sa.Boolean(), nullable=False, + server_default=sa.text("false"), + ), + ) + op.add_column( + "ml_settings", + sa.Column( + "process_auto_apply_threshold", sa.Float(), nullable=False, + server_default="0.90", + ), + ) + op.add_column( + "ml_settings", + sa.Column( + "process_conflict_threshold", sa.Float(), nullable=False, + server_default="0.50", + ), + ) + op.add_column( + "presentation_review", + sa.Column( + "mode", sa.String(16), nullable=False, + server_default="chrome", + ), + ) + + +def downgrade() -> None: + op.drop_column("presentation_review", "mode") + op.drop_column("ml_settings", "process_conflict_threshold") + op.drop_column("ml_settings", "process_auto_apply_threshold") + op.drop_column("ml_settings", "process_auto_apply_enabled") diff --git a/backend/app/api/gallery.py b/backend/app/api/gallery.py index 410852e..7ef43f1 100644 --- a/backend/app/api/gallery.py +++ b/backend/app/api/gallery.py @@ -236,8 +236,10 @@ async def jump(): # content", surfaced in the gallery's Show-hidden review strip. ----------- @gallery_bp.route("/hidden-review", methods=["GET"]) async def hidden_review(): - """Unresolved presentation auto-hide flags, most-concerning first (highest - content score) — for the gallery's Hidden-view review strip.""" + """Unresolved system-tag auto-apply review flags (chrome + process, #1464), + most-concerning first (highest content score) — for the review strip. `mode` + tells the client whether the flagged tag hid the image ('chrome') or left it + visible ('process'), which decides the resolve labels (un-hide vs remove-tag).""" ptag = aliased(Tag) ctag = aliased(Tag) async with get_session() as session: @@ -247,6 +249,7 @@ async def hidden_review(): PresentationReview.tag_id, PresentationReview.conflict_tag_id, PresentationReview.conflict_score, + PresentationReview.mode, ImageRecord.path, ImageRecord.thumbnail_path, ImageRecord.sha256, ImageRecord.mime, ptag.name.label("tag_name"), @@ -266,6 +269,7 @@ async def hidden_review(): "conflict_tag_id": r.conflict_tag_id, "conflict_name": r.conflict_name, "conflict_score": r.conflict_score, + "mode": r.mode, "thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime), "image_url": image_url(r.path), } diff --git a/backend/app/api/ml_admin.py b/backend/app/api/ml_admin.py index 52e26fd..6afff73 100644 --- a/backend/app/api/ml_admin.py +++ b/backend/app/api/ml_admin.py @@ -42,6 +42,9 @@ _EDITABLE = ( "presentation_auto_apply_enabled", "presentation_auto_apply_threshold", "presentation_conflict_threshold", + "process_auto_apply_enabled", + "process_auto_apply_threshold", + "process_conflict_threshold", "embedder_model_name", "embedder_model_version", *_DETECTOR_FIELDS, @@ -102,6 +105,9 @@ async def get_settings(): "presentation_auto_apply_enabled": s.presentation_auto_apply_enabled, "presentation_auto_apply_threshold": s.presentation_auto_apply_threshold, "presentation_conflict_threshold": s.presentation_conflict_threshold, + "process_auto_apply_enabled": s.process_auto_apply_enabled, + "process_auto_apply_threshold": s.process_auto_apply_threshold, + "process_conflict_threshold": s.process_conflict_threshold, "embedder_model_name": s.embedder_model_name, **{f: getattr(s, f) for f in _DETECTOR_FIELDS}, } @@ -162,6 +168,12 @@ def _validate(p: dict) -> str | None: return "presentation_auto_apply_threshold must be between 0.5 and 0.999" if not (0.0 <= float(p["presentation_conflict_threshold"]) <= 1.0): return "presentation_conflict_threshold must be between 0 and 1" + # Process auto-apply (#1464). wip/editor stay VISIBLE so a false apply is + # low-harm (excludes-from-training + a review flag), but keep the same bar. + if not (0.5 <= float(p["process_auto_apply_threshold"]) <= 0.999): + return "process_auto_apply_threshold must be between 0.5 and 0.999" + if not (0.0 <= float(p["process_conflict_threshold"]) <= 1.0): + return "process_conflict_threshold must be between 0 and 1" # Embedder model swap (#1190): both must be non-empty. Changing them means a # different embedding space — the operator must re-embed + retrain after. for key in ("embedder_model_name", "embedder_model_version"): diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index f657af3..20dd691 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -171,9 +171,14 @@ def make_celery() -> Celery: }, "presentation-auto-apply-daily": { "task": "backend.app.tasks.ml.scheduled_presentation_auto_apply", - "schedule": 86400.0, # auto-hide banner/editor chrome (#141); + "schedule": 86400.0, # auto-hide banner chrome (#141); # no-op unless presentation_auto_apply_enabled }, + "process-auto-apply-daily": { + "task": "backend.app.tasks.ml.scheduled_process_auto_apply", + "schedule": 86400.0, # auto-tag wip/editor process art (#1464); + # no-op unless process_auto_apply_enabled (opt-in) + }, "prune-presentation-reviews-daily": { "task": "backend.app.tasks.ml.prune_presentation_reviews", "schedule": 86400.0, # retention: drop resolved review flags >30d diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py index 0d38e91..574d16e 100644 --- a/backend/app/models/ml_settings.py +++ b/backend/app/models/ml_settings.py @@ -85,12 +85,14 @@ class MLSettings(Base): Float, nullable=False, default=0.95 ) # -- Presentation chrome auto-hide (#141) ------------------------------- - # banner / editor screenshot auto-apply on the sweep with their OWN flat - # threshold (decoupled from content-head graduation). Hiding is consequential - # so it runs HIGH. `wip` is never auto-applied. When an image would be - # auto-hidden but ALSO scores >= presentation_conflict_threshold on a content - # head, it's still hidden but flagged for review (PresentationReview) instead - # of buried silently. ON by default (opt-out); every auto-tag is reversible. + # `banner` (chrome — clusters on UI, not content) auto-applies on the sweep + # with its OWN flat threshold (decoupled from content-head graduation) and is + # HIDDEN from the gallery. Hiding is consequential so it runs HIGH. When an + # image would be auto-hidden but ALSO scores >= presentation_conflict_threshold + # on a content head, it's still hidden but flagged for review + # (PresentationReview, mode='chrome') instead of buried silently. ON by default + # (opt-out); every auto-tag is reversible. NOTE (#1464): `wip` + `editor + # screenshot` are no longer chrome — they went to the PROCESS path below. presentation_auto_apply_enabled: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True ) @@ -100,6 +102,26 @@ class MLSettings(Base): presentation_conflict_threshold: Mapped[float] = mapped_column( Float, nullable=False, default=0.50 ) + # -- Process auto-apply (#1464) ---------------------------------------- + # `wip` / `editor screenshot` are PROCESS art — unfinished pieces + program + # screenshots that must stay OUT of head/CCIP training but, unlike chrome, + # remain VISIBLE in the gallery (operator 2026-07-12). They auto-apply on the + # sweep with their OWN flat threshold and a PROVISIONAL source (`process_auto`, + # in training_data._AUTO_SOURCES) so the head NEVER trains on its own output — + # it learns only from title (`wip_title`) + manual labels, which breaks the + # runaway loop. When a process tag would be applied but the image ALSO scores + # >= process_conflict_threshold on a content head, it's flagged for review + # (PresentationReview, mode='process') rather than silently marked. OFF by + # default — a new whole-library auto-tagger is opt-in; every auto-tag reversible. + process_auto_apply_enabled: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False + ) + process_auto_apply_threshold: Mapped[float] = mapped_column( + Float, nullable=False, default=0.90 + ) + process_conflict_threshold: Mapped[float] = mapped_column( + Float, nullable=False, default=0.50 + ) # Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069); # existing libraries keep their stored value until the operator re-embeds. embedder_model_version: Mapped[str] = mapped_column( diff --git a/backend/app/models/presentation_review.py b/backend/app/models/presentation_review.py index d6f91c4..73da13f 100644 --- a/backend/app/models/presentation_review.py +++ b/backend/app/models/presentation_review.py @@ -1,15 +1,17 @@ -"""PresentationReview — an auto-hidden presentation tag that ALSO looked like -real content, flagged for operator review (milestone 141). +"""PresentationReview — a system-tag the auto-apply sweep applied that ALSO looked +like real content, flagged for operator review (milestone 141 + #1464). -When the auto-apply sweep hides an image as chrome (banner / editor screenshot) -but the image ALSO scores highly on a content head, it still hides it but records -this row so the Hidden view can surface it ("⚠ also looks like ") -for a keep-hidden / un-hide decision. Resolved rows are pruned by retention. +When a sweep applies a system tag but the image ALSO scores highly on a content +head, it still applies the tag but records this row so a review strip can surface +it ("⚠ also looks like "). Two modes (#1464): 'chrome' (banner — +image is HIDDEN, review is keep-hidden / un-hide) and 'process' (wip / editor +screenshot — image stays VISIBLE, review is confirm / remove-tag). Resolved rows +are pruned by retention. """ from datetime import datetime -from sqlalchemy import DateTime, Float, ForeignKey, func +from sqlalchemy import DateTime, Float, ForeignKey, String, func from sqlalchemy.orm import Mapped, mapped_column from .base import Base @@ -31,6 +33,12 @@ class PresentationReview(Base): ForeignKey("tag.id", ondelete="SET NULL"), nullable=True ) conflict_score: Mapped[float] = mapped_column(Float, nullable=False) + # Which sweep flagged this (#1464): 'chrome' (banner, hidden) or 'process' + # (wip / editor screenshot, shown). Drives which review strip surfaces it and + # what "resolve" means (un-hide vs remove-tag). Existing rows backfill 'chrome'. + mode: Mapped[str] = mapped_column( + String(16), nullable=False, default="chrome", server_default="chrome" + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py index c27ebd3..8d5a256 100644 --- a/backend/app/models/tag.py +++ b/backend/app/models/tag.py @@ -43,14 +43,19 @@ class TagKind(StrEnum): # to keep historic tag rows queryable. -# The seeded system tags (migration 0075). PRESENTATION tags additionally -# hide from whole-image similarity results — they cluster on UI chrome, not -# content. `wip` is real art: only the training pipelines exclude it. +# The seeded system tags (migration 0075). Two behavior groups (#1464): +# CHROME (banner): clusters on UI chrome, not content → HIDDEN from the default +# gallery + from similarity; auto-applied via the sweep's chrome mode. +# PROCESS (wip, editor screenshot): real-but-unfinished art / program screenshots +# → SHOWN in the gallery (operator 2026-07-12), but excluded from the Explore +# rabbit-hole; auto-applied via the sweep's process mode (provisional source, +# ring-loud review guard). +# ALL three are excluded from OTHER concepts' head/CCIP training (training-hygiene, +# keyed on is_system); a system tag's OWN head trains on them — that's what makes +# auto-flagging work. SYSTEM_TAG_NAMES = ("wip", "banner", "editor screenshot") -PRESENTATION_SYSTEM_TAGS = ("banner", "editor screenshot") -# `wip` marks real-but-unfinished art. It's kept in the gallery's own "similar" -# results (#1274), but the Explore rabbit-hole opts to hide it (exclude_wip) so a -# browse doesn't keep surfacing work-in-progress (operator, 2026-07-08). +CHROME_SYSTEM_TAGS = ("banner",) +PROCESS_SYSTEM_TAGS = ("wip", "editor screenshot") WIP_SYSTEM_TAG = "wip" image_tag = Table( diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index 8d11a95..7f365a3 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -31,7 +31,7 @@ from ..models import ( Tag, TagPositiveConfirmation, ) -from ..models.tag import PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG, image_tag +from ..models.tag import CHROME_SYSTEM_TAGS, PROCESS_SYSTEM_TAGS, image_tag from .pagination import decode_cursor, encode_cursor from .tag_query import ( fandom_join_alias, @@ -419,16 +419,17 @@ class GalleryService: async def _hidden_tag_ids( self, include_hidden, tag_ids, tag_or_groups, ) -> list[int] | None: - """Presentation-chrome tag ids to implicitly exclude from a gallery query, - or None. None when the caller asked to include hidden, when the operator - is explicitly filtering FOR a presentation tag (they clearly want to see - it), or when no presentation tags exist. (milestone 141)""" + """Chrome (banner) tag ids to implicitly exclude from a gallery query, or + None. None when the caller asked to include hidden, when the operator is + explicitly filtering FOR a chrome tag (they clearly want to see it), or when + no chrome tags exist. (milestone 141; #1464: editor screenshot is now PROCESS + — shown — so only `banner` hides here.)""" if include_hidden: return None rows = await self.session.execute( select(Tag.id).where( Tag.is_system.is_(True), - Tag.name.in_(PRESENTATION_SYSTEM_TAGS), + Tag.name.in_(CHROME_SYSTEM_TAGS), ) ) pres = [r[0] for r in rows] @@ -749,15 +750,16 @@ class GalleryService: eff = _effective_date_col() stmt = select(ImageRecord, Post.post_date, eff.label("eff")) stmt = _outer_join_primary_post(stmt) - # Presentation images (banner / editor-screenshot system tags, #128) - # cluster on UI chrome rather than content, so near any one of them - # they'd fill the grid. Excluded from CANDIDATES only — the anchor - # itself may be a banner. `wip` stays surfaced here by default (real art; - # only the training pipelines exclude it), but the Explore rabbit-hole - # passes exclude_wip to also drop work-in-progress (operator, 2026-07-08). - excluded_system_tags = PRESENTATION_SYSTEM_TAGS + # Chrome (banner, #128) clusters on UI rather than content, so near any one + # of them they'd fill the grid → excluded from CANDIDATES always (the anchor + # itself may be a banner). PROCESS art (wip / editor screenshot) stays + # surfaced here by default (real content; only the training pipelines exclude + # it), but the Explore rabbit-hole passes exclude_wip to also drop the whole + # process group so a browse doesn't keep surfacing work-in-progress + # (operator, 2026-07-08; #1464 — editor now rides with wip here). + excluded_system_tags = CHROME_SYSTEM_TAGS if exclude_wip: - excluded_system_tags = (*PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG) + excluded_system_tags = (*CHROME_SYSTEM_TAGS, *PROCESS_SYSTEM_TAGS) presentation = ( select(image_tag.c.image_record_id) .join(Tag, Tag.id == image_tag.c.tag_id) diff --git a/backend/app/services/ml/heads.py b/backend/app/services/ml/heads.py index 423d70d..12472cf 100644 --- a/backend/app/services/ml/heads.py +++ b/backend/app/services/ml/heads.py @@ -39,7 +39,7 @@ from ...models import ( TagPositiveConfirmation, TagSuggestionRejection, ) -from ...models.tag import PRESENTATION_SYSTEM_TAGS, image_tag +from ...models.tag import CHROME_SYSTEM_TAGS, PROCESS_SYSTEM_TAGS, image_tag from .training_data import ( _AUTO_SOURCES, _auto_apply_point, @@ -759,18 +759,42 @@ def auto_apply_sweep( _PRESENTATION_SOURCE = "presentation_auto" +_PROCESS_SOURCE = "process_auto" + +# System-tag auto-apply modes (#1464). Both modes run the identical sweep — apply +# a system tag at a flat threshold with a PROVISIONAL source + a ring-loud review +# guard — and differ ONLY in which tags, which settings knobs, and which +# source/review-mode. 'chrome' (banner) is HIDDEN from the gallery; 'process' +# (wip / editor screenshot) stays VISIBLE (the hide is a gallery-query effect of +# the tag's group membership, not of this sweep). +_SWEEP_MODES = { + "chrome": { + "names": CHROME_SYSTEM_TAGS, + "enabled": "presentation_auto_apply_enabled", + "threshold": "presentation_auto_apply_threshold", + "conflict": "presentation_conflict_threshold", + "source": _PRESENTATION_SOURCE, + }, + "process": { + "names": PROCESS_SYSTEM_TAGS, + "enabled": "process_auto_apply_enabled", + "threshold": "process_auto_apply_threshold", + "conflict": "process_conflict_threshold", + "source": _PROCESS_SOURCE, + }, +} -def _presentation_heads(session: Session, embedding_version: str): - """Trained heads for the presentation chrome tags (banner / editor screenshot). - They fire at the FLAT presentation threshold regardless of graduation — a head - exists once the operator has labelled enough chrome (head_min_positives).""" +def _system_tag_heads(session: Session, embedding_version: str, names): + """Trained heads for a system-tag group (chrome banner / process wip+editor). + They fire at the group's FLAT threshold regardless of graduation — a head + exists once the operator has labelled enough (head_min_positives).""" return session.execute( select(TagHead.tag_id, Tag.name, TagHead.weights, TagHead.bias) .join(Tag, Tag.id == TagHead.tag_id) .where(TagHead.embedding_version == embedding_version) .where(Tag.is_system.is_(True)) - .where(Tag.name.in_(PRESENTATION_SYSTEM_TAGS)) + .where(Tag.name.in_(names)) ).all() @@ -802,27 +826,33 @@ def _valued_image_ids(session: Session) -> set[int]: return {r[0] for r in rows} -def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> dict: - """Auto-hide presentation chrome (banner / editor screenshot) at the FLAT - presentation threshold (#141) — NOT the per-head graduated threshold. Two - guards keep it safe: (1) never hide an image carrying a human/confirmed content - tag; (2) if an image about to be hidden ALSO scores >= the conflict threshold - on a content head, still hide it but flag it (PresentationReview) so the Hidden - view surfaces "also looks like " for review. No-op unless - presentation_auto_apply_enabled. numpy-only (no sklearn). Returns - {n_applied, n_flagged, concepts}.""" +def system_tag_auto_apply_sweep( + session: Session, *, mode: str, dry_run: bool = False +) -> dict: + """Auto-apply a system-tag group at its FLAT threshold. mode='chrome' (banner, + #141) hides the image; mode='process' (wip / editor screenshot, #1464) keeps it + VISIBLE — the ONLY difference is the tag group's gallery membership, not this + sweep. Two guards keep it safe: (1) never touch an image carrying a + human/confirmed content tag; (2) if the image ALSO scores >= the conflict + threshold on a content head, still apply but flag it (PresentationReview, + mode=) so the review strip surfaces "also looks like ". The source is + PROVISIONAL so the head never trains on its own output. No-op unless the mode's + enabled flag is set. numpy-only (no sklearn). Returns {n_applied, n_flagged, + concepts}.""" import numpy as np from sqlalchemy.dialects.postgresql import insert as pg_insert + cfg = _SWEEP_MODES[mode] settings = _settings(session) - if not dry_run and not settings.presentation_auto_apply_enabled: + if not dry_run and not getattr(settings, cfg["enabled"]): return {"n_applied": 0, "n_flagged": 0, "concepts": []} ver = settings.embedder_model_version - pres = _presentation_heads(session, ver) + pres = _system_tag_heads(session, ver, cfg["names"]) if not pres: return {"n_applied": 0, "n_flagged": 0, "concepts": []} - thr = float(settings.presentation_auto_apply_threshold) - conflict_thr = float(settings.presentation_conflict_threshold) + thr = float(getattr(settings, cfg["threshold"])) + conflict_thr = float(getattr(settings, cfg["conflict"])) + source = cfg["source"] Wp = np.vstack([np.asarray(r.weights, dtype=np.float32) for r in pres]) bp = np.asarray([r.bias for r in pres], dtype=np.float32) @@ -884,11 +914,13 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di pg_insert(image_tag) .values( image_record_id=iid, tag_id=tid, - source=_PRESENTATION_SOURCE, + source=source, ) .on_conflict_do_nothing() ) - # Guard 2: also looks like content → hide but flag for review. + # Guard 2: also looks like real content → still apply, but flag it + # for the review strip instead of silently marking (chrome hides, + # process stays visible — either way the operator gets a heads-up). if Wc is not None and float(max_c[idx]) >= conflict_thr: n_flagged += 1 if not dry_run: @@ -898,6 +930,7 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di image_record_id=iid, tag_id=tid, conflict_tag_id=conf_tag_ids[int(arg_c[idx])], conflict_score=float(max_c[idx]), + mode=mode, ) .on_conflict_do_nothing() ) diff --git a/backend/app/services/ml/training_data.py b/backend/app/services/ml/training_data.py index 8fcf8cc..ff6ec78 100644 --- a/backend/app/services/ml/training_data.py +++ b/backend/app/services/ml/training_data.py @@ -29,7 +29,10 @@ from ...models.tag import image_tag # a CCIP reference) unless the operator confirms them (milestone 139). Keeping # auto-applied predictions out of training is what makes them "soft" — a misfire # can't reinforce itself, so the retraction sweep can actually drop it. -_AUTO_SOURCES = ("head_auto", "ccip_auto", "ml_auto", "presentation_auto") +# `process_auto` (#1464): wip/editor screenshot applied by the process sweep are +# ALSO provisional — the head must learn only from title (`wip_title`) + manual +# labels, never its own auto-applied output, or it would runaway (operator 2026-07-12). +_AUTO_SOURCES = ("head_auto", "ccip_auto", "ml_auto", "presentation_auto", "process_auto") def _hygiene_excluded_ids(session: Session) -> set[int]: diff --git a/backend/app/tasks/ml.py b/backend/app/tasks/ml.py index 0f6343a..e6bec0c 100644 --- a/backend/app/tasks/ml.py +++ b/backend/app/tasks/ml.py @@ -599,15 +599,33 @@ def scheduled_ccip_auto_apply() -> str: 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 + """Auto-hide presentation chrome (banner) on a daily passive sweep (#141). + No-op unless presentation_auto_apply_enabled. Idempotent — already-tagged 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 system_tag_auto_apply_sweep SessionLocal = _sync_session_factory() with SessionLocal() as session: - result = presentation_auto_apply_sweep(session) + result = system_tag_auto_apply_sweep(session, mode="chrome") + return f"applied={result['n_applied']} flagged={result['n_flagged']}" + + +@celery.task( + name="backend.app.tasks.ml.scheduled_process_auto_apply", + soft_time_limit=1800, time_limit=2100, +) +def scheduled_process_auto_apply() -> str: + """Auto-apply the PROCESS system tags (wip / editor screenshot) on a daily + passive sweep (#1464) — provisional source, ring-loud review guard, image stays + VISIBLE. No-op unless process_auto_apply_enabled (opt-in). Idempotent — + already-tagged/rejected images are skipped — so an interrupted run just re-runs + next cycle (the recovery). Wall-clock bounded by the task time limits.""" + from ..services.ml.heads import system_tag_auto_apply_sweep + + SessionLocal = _sync_session_factory() + with SessionLocal() as session: + result = system_tag_auto_apply_sweep(session, mode="process") return f"applied={result['n_applied']} flagged={result['n_flagged']}" diff --git a/tests/test_gallery_service.py b/tests/test_gallery_service.py index 96266cc..4436a0d 100644 --- a/tests/test_gallery_service.py +++ b/tests/test_gallery_service.py @@ -82,24 +82,28 @@ async def _system_tag(db, name): @pytest.mark.asyncio async def test_scroll_hides_presentation_chrome_by_default(db): - # banner / editor screenshot (presentation system tags) are hidden from the - # default gallery; wip (also a system tag) is NOT — it's real, in-progress - # art (milestone 141). Seeded system tags survive the harness TRUNCATE. - imgs = await _seed_images(db, 3, sha_prefix="p") + # banner (chrome) is hidden from the default gallery; wip AND editor screenshot + # (the PROCESS system tags) are NOT — they're real art / process shots that stay + # visible (milestone 141 + #1464). Seeded system tags survive the harness TRUNCATE. + imgs = await _seed_images(db, 4, sha_prefix="p") banner = await _system_tag(db, "banner") wip = await _system_tag(db, "wip") + editor = await _system_tag(db, "editor screenshot") await db.execute(image_tag.insert().values( image_record_id=imgs[0].id, tag_id=banner.id, source="manual")) await db.execute(image_tag.insert().values( image_record_id=imgs[1].id, tag_id=wip.id, source="manual")) + await db.execute(image_tag.insert().values( + image_record_id=imgs[3].id, tag_id=editor.id, source="manual")) await db.flush() svc = GalleryService(db) - # Default: the banner image is hidden; the wip image + the plain image stay. + # Default: only the banner image is hidden; wip + editor + plain all stay. default_ids = {i.id for i in (await svc.scroll(cursor=None, limit=10)).images} assert imgs[0].id not in default_ids # banner hidden assert imgs[1].id in default_ids # wip visible assert imgs[2].id in default_ids # plain visible + assert imgs[3].id in default_ids # editor screenshot visible (#1464) # include_hidden surfaces the banner image (the Hidden view). shown = {i.id for i in ( diff --git a/tests/test_presentation_auto_apply.py b/tests/test_presentation_auto_apply.py index ac7a4d7..c549fbb 100644 --- a/tests/test_presentation_auto_apply.py +++ b/tests/test_presentation_auto_apply.py @@ -15,7 +15,7 @@ from backend.app.models import ( from backend.app.models.tag import image_tag from backend.app.services.ml.heads import ( auto_apply_sweep, - presentation_auto_apply_sweep, + system_tag_auto_apply_sweep, ) pytestmark = pytest.mark.integration @@ -70,7 +70,7 @@ def test_presentation_sweep_hides_chrome(db_sync): _head(db_sync, banner.id, 0, weight=3.0) img = _img(db_sync, "a" * 64, _emb(0)) db_sync.commit() - res = presentation_auto_apply_sweep(db_sync) + res = system_tag_auto_apply_sweep(db_sync, mode="chrome") assert res["n_applied"] == 1 assert _source(db_sync, img.id, banner.id) == "presentation_auto" @@ -86,7 +86,7 @@ def test_presentation_sweep_hard_skips_valued_image(db_sync): db_sync.execute(image_tag.insert().values( image_record_id=img.id, tag_id=content.id, source="manual")) db_sync.commit() - res = presentation_auto_apply_sweep(db_sync) + res = system_tag_auto_apply_sweep(db_sync, mode="chrome") assert res["n_applied"] == 0 assert _source(db_sync, img.id, banner.id) is None @@ -102,7 +102,7 @@ def test_presentation_sweep_flags_conflict(db_sync): _head(db_sync, content.id, 0, weight=1.0) # content head also fires img = _img(db_sync, "c" * 64, _emb(0)) db_sync.commit() - res = presentation_auto_apply_sweep(db_sync) + res = system_tag_auto_apply_sweep(db_sync, mode="chrome") assert res["n_applied"] == 1 assert res["n_flagged"] == 1 assert _source(db_sync, img.id, banner.id) == "presentation_auto" @@ -123,7 +123,7 @@ def test_presentation_sweep_disabled_is_noop(db_sync): _head(db_sync, banner.id, 0, weight=3.0) img = _img(db_sync, "d" * 64, _emb(0)) db_sync.commit() - res = presentation_auto_apply_sweep(db_sync) + res = system_tag_auto_apply_sweep(db_sync, mode="chrome") assert res["n_applied"] == 0 assert _source(db_sync, img.id, banner.id) is None @@ -134,7 +134,7 @@ def test_presentation_sweep_ignores_wip(db_sync): _head(db_sync, wip.id, 0, weight=3.0) img = _img(db_sync, "e" * 64, _emb(0)) db_sync.commit() - res = presentation_auto_apply_sweep(db_sync) + res = system_tag_auto_apply_sweep(db_sync, mode="chrome") assert res["n_applied"] == 0 assert _source(db_sync, img.id, wip.id) is None diff --git a/tests/test_process_auto_apply.py b/tests/test_process_auto_apply.py new file mode 100644 index 0000000..3824399 --- /dev/null +++ b/tests/test_process_auto_apply.py @@ -0,0 +1,152 @@ +"""Process-group auto-apply sweep (#1464): wip / editor screenshot auto-tag at a +flat threshold with a PROVISIONAL source (`process_auto`) so the head never trains +on its own output, and stay VISIBLE (unlike chrome). Mirrors the chrome guards. +numpy-only (no sklearn), tested directly via the sync session.""" +import pytest +from sqlalchemy import select + +from backend.app.models import ( + ImageRecord, + MLSettings, + PresentationReview, + Tag, + TagHead, + TagKind, +) +from backend.app.models.tag import image_tag +from backend.app.services.ml.heads import system_tag_auto_apply_sweep +from backend.app.services.ml.training_data import _ids_with_tag + +pytestmark = pytest.mark.integration + + +def _emb(slot: int) -> list[float]: + v = [0.0] * 1152 + v[slot] = 3.0 + return v + + +def _img(db, sha: str, emb) -> 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 _head(db, tag_id: int, slot: int, *, weight=1.0): + s = db.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one() + w = [0.0] * 1152 + w[slot] = weight + db.add(TagHead( + tag_id=tag_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, + )) + + +def _system_tag(db, name): + return db.execute( + select(Tag).where(Tag.is_system.is_(True), Tag.name == name) + ).scalar_one() + + +def _enable_process(db): + # process auto-apply is opt-in (default False) — turn it on for these tests. + db.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one().process_auto_apply_enabled = True + + +def _source(db, image_id, tag_id): + return db.execute( + select(image_tag.c.source) + .where(image_tag.c.image_record_id == image_id) + .where(image_tag.c.tag_id == tag_id) + ).scalar_one_or_none() + + +def test_process_sweep_applies_wip_and_editor(db_sync): + _enable_process(db_sync) + wip = _system_tag(db_sync, "wip") + editor = _system_tag(db_sync, "editor screenshot") + _head(db_sync, wip.id, 0, weight=3.0) + _head(db_sync, editor.id, 1, weight=3.0) + w_img = _img(db_sync, "a" * 64, _emb(0)) + e_img = _img(db_sync, "b" * 64, _emb(1)) + db_sync.commit() + res = system_tag_auto_apply_sweep(db_sync, mode="process") + assert res["n_applied"] == 2 + assert _source(db_sync, w_img.id, wip.id) == "process_auto" + assert _source(db_sync, e_img.id, editor.id) == "process_auto" + + +def test_process_sweep_disabled_by_default_is_noop(db_sync): + # process_auto_apply_enabled defaults False (opt-in) — no enable = no-op. + wip = _system_tag(db_sync, "wip") + _head(db_sync, wip.id, 0, weight=3.0) + img = _img(db_sync, "c" * 64, _emb(0)) + db_sync.commit() + res = system_tag_auto_apply_sweep(db_sync, mode="process") + assert res["n_applied"] == 0 + assert _source(db_sync, img.id, wip.id) is None + + +def test_process_sweep_skips_valued_image(db_sync): + # Guard 1: never auto-apply to an image the operator already content-tagged. + _enable_process(db_sync) + wip = _system_tag(db_sync, "wip") + _head(db_sync, wip.id, 0, weight=3.0) + content = Tag(name="mychar", kind=TagKind.character) + db_sync.add(content) + db_sync.flush() + img = _img(db_sync, "d" * 64, _emb(0)) + db_sync.execute(image_tag.insert().values( + image_record_id=img.id, tag_id=content.id, source="manual")) + db_sync.commit() + res = system_tag_auto_apply_sweep(db_sync, mode="process") + assert res["n_applied"] == 0 + assert _source(db_sync, img.id, wip.id) is None + + +def test_process_sweep_flags_conflict_with_process_mode(db_sync): + # Guard 2: also scores high on a content head → still applied, but flagged + # for review with mode='process' (the ring-loud guard). + _enable_process(db_sync) + wip = _system_tag(db_sync, "wip") + _head(db_sync, wip.id, 0, weight=3.0) + content = Tag(name="looksreal", kind=TagKind.general) + db_sync.add(content) + db_sync.flush() + _head(db_sync, content.id, 0, weight=1.0) + img = _img(db_sync, "e" * 64, _emb(0)) + db_sync.commit() + res = system_tag_auto_apply_sweep(db_sync, mode="process") + assert res["n_applied"] == 1 + assert res["n_flagged"] == 1 + flag = db_sync.execute( + select(PresentationReview).where( + PresentationReview.image_record_id == img.id, + PresentationReview.tag_id == wip.id, + ) + ).scalar_one() + assert flag.mode == "process" + assert flag.conflict_tag_id == content.id + + +def test_process_auto_source_never_trains_head(db_sync): + # The runaway break: a wip tag the process sweep applied (source='process_auto') + # is NOT a training positive; a title-heuristic / manual one IS. So the head + # learns only from trusted labels, never its own output. + wip = _system_tag(db_sync, "wip") + auto_img = _img(db_sync, "f" * 64, _emb(0)) + title_img = _img(db_sync, "0" * 64, _emb(1)) + db_sync.execute(image_tag.insert().values( + image_record_id=auto_img.id, tag_id=wip.id, source="process_auto")) + db_sync.execute(image_tag.insert().values( + image_record_id=title_img.id, tag_id=wip.id, source="wip_title")) + db_sync.commit() + positives = set(_ids_with_tag(db_sync, wip.id)) + assert title_img.id in positives # trusted label trains the head + assert auto_img.id not in positives # its own auto-applied output does NOT