feat(system-tags): process vs chrome groups + WIP provisional auto-apply (#1464)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 39s
CI / integration (push) Successful in 3m48s

Backend for the system-tag behavior refactor (milestone #157). editor screenshot
moves from chrome (hidden) to the PROCESS group (shown, like wip); wip+editor gain
provisional auto-apply so they stop needing endless manual identification —
without a runaway loop.

- tag.py: split PRESENTATION_SYSTEM_TAGS → CHROME_SYSTEM_TAGS (banner) +
  PROCESS_SYSTEM_TAGS (wip, editor screenshot).
- heads.py: generalize presentation_auto_apply_sweep → system_tag_auto_apply_sweep
  (mode chrome|process). Same Guard 1 (skip human/confirmed) + Guard 2 (ring-loud
  conflict → PresentationReview). process mode uses source 'process_auto' and does
  NOT hide (hide is a gallery-query effect of group membership).
- training_data._AUTO_SOURCES += 'process_auto' → the head never trains on its own
  auto-applied output; only wip_title/manual train it (the runaway break).
- ml_settings: process_auto_apply_enabled (OFF, opt-in) + threshold + conflict
  threshold. presentation_review.mode ('chrome'|'process'). Migration 0086.
- gallery_service: default-hide reads CHROME only (editor now shows); Explore
  neighbors exclude the whole PROCESS group.
- tasks/ml + celery beat: scheduled_process_auto_apply (daily, opt-in); prune
  covers both modes.
- api: ml_admin process_* CRUD+validation; hidden-review returns mode.
- tests: rename chrome sweep calls; new test_process_auto_apply (apply, guards,
  mode flag, no-self-train); gallery test asserts editor now visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 23:15:59 -04:00
parent 0da0e47784
commit ad2a5fc5fe
14 changed files with 405 additions and 76 deletions
+16 -14
View File
@@ -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)
+54 -21
View File
@@ -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 <X>" 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=<mode>) so the review strip surfaces "also looks like <X>". 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()
)
+4 -1
View File
@@ -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]: