feat(system-tags): process vs chrome groups + WIP provisional auto-apply (#1464)
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:
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user