feat: retire the sketch/doodle WIP title tier, and the review strip asks "Is this a WIP?" (milestone 430)
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m24s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m35s
CI and images / smoke-web (push) Successful in 56s
CI and images / promote (push) Successful in 1s

The soft tier (#1474) tagged `wip` on any post titled sketch, doodle or
scribble: 6,096 of the library's 8,876 wip tags. Its conflict audit flagged
most of them, because finished art scores >= 0.5 on some content head,
which filled the Gallery strip with 2,086 cards. A "sketch" is usually
finished work, so the operator retired the tier. The artist's own
"WIP" / "work in progress" title rule and human wip tags stay.

- Removed: the soft matcher, source and prefilter; the importer and backfill
  soft branches; soft_wip_conflict_audit with its task and beat; the
  wip_soft_title_tagging_enabled setting, API field and toggle; and
  wip_title_soft from _AUTO_SOURCES.
- Migration 0112: a soft tag the operator confirmed, or kept from the strip,
  becomes `manual`. Every other soft tag is deleted, along with the open
  review cards whose tag is gone. The column is dropped.
- Review strip (#4424): each card asks "Is this a WIP?" (or banner, and so on),
  and the buttons read "Is a WIP" / "Is not a WIP". The content tag it also
  scored on is shown as the reason it was flagged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-25 08:20:28 -04:00
co-authored by Claude Opus 5.5
parent 83e1382812
commit 7b1570f2a5
16 changed files with 220 additions and 283 deletions
@@ -0,0 +1,78 @@
"""Retire the sketch/doodle WIP title tier — its tags, its review flags, its toggle.
Milestone 430, #4428. The soft tier (#1474) tagged `wip` on any post titled
sketch / doodle / scribble. Measured on the operator's library it was 6,096 of
8,876 wip tags, and its conflict audit filled the Gallery's review strip with
2,086 cards, because most finished art scores >= 0.5 on some content head. A
"sketch" is usually finished work, so the operator retired it (2026-09-25).
Data:
* A soft tag the operator stood behind is kept and relabelled `manual`: one they
confirmed (tag_positive_confirmation), or one whose review flag they resolved
while leaving the tag on (the strip's "Keep tag").
* Every other `wip_title_soft` row is deleted.
* Unresolved review flags whose tag is no longer on the image are deleted: the
question they ask no longer applies. That is the audit's cards, and any older
orphan the same way.
Then `import_settings.wip_soft_title_tagging_enabled` is dropped. The downgrade
restores the column only; deleted tags are not recreated.
Revision ID: 0112
Revises: 0111
Create Date: 2026-09-25
"""
import sqlalchemy as sa
from alembic import op
revision = "0112"
down_revision = "0111"
branch_labels = None
depends_on = None
def retire_soft_wip_tags(conn) -> None:
"""The data half, on a plain connection, so a test can run it directly."""
conn.execute(sa.text("""
UPDATE image_tag it SET source = 'manual'
WHERE it.source = 'wip_title_soft'
AND (
EXISTS (
SELECT 1 FROM tag_positive_confirmation c
WHERE c.image_record_id = it.image_record_id AND c.tag_id = it.tag_id
)
OR EXISTS (
SELECT 1 FROM presentation_review pr
WHERE pr.image_record_id = it.image_record_id AND pr.tag_id = it.tag_id
AND pr.resolved_at IS NOT NULL
)
)
"""))
conn.execute(sa.text("DELETE FROM image_tag WHERE source = 'wip_title_soft'"))
conn.execute(sa.text("""
DELETE FROM presentation_review pr
WHERE pr.resolved_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM image_tag it
WHERE it.image_record_id = pr.image_record_id AND it.tag_id = pr.tag_id
)
"""))
def upgrade():
retire_soft_wip_tags(op.get_bind())
op.drop_column("import_settings", "wip_soft_title_tagging_enabled")
def downgrade():
op.add_column(
"import_settings",
sa.Column(
"wip_soft_title_tagging_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
-7
View File
@@ -57,7 +57,6 @@ _EDITABLE_FIELDS = (
"translation_target_lang", "translation_target_lang",
"translation_min_confidence", "translation_min_confidence",
"wip_title_tagging_enabled", "wip_title_tagging_enabled",
"wip_soft_title_tagging_enabled",
) )
# Per-host external-download toggles — all plain booleans, validated uniformly. # Per-host external-download toggles — all plain booleans, validated uniformly.
@@ -196,12 +195,6 @@ async def update_import_settings():
return jsonify( return jsonify(
{"error": "wip_title_tagging_enabled must be a boolean"} {"error": "wip_title_tagging_enabled must be a boolean"}
), 400 ), 400
if "wip_soft_title_tagging_enabled" in body and not isinstance(
body["wip_soft_title_tagging_enabled"], bool
):
return jsonify(
{"error": "wip_soft_title_tagging_enabled must be a boolean"}
), 400
async with get_session() as session: async with get_session() as session:
row = await ImportSettings.load(session) row = await ImportSettings.load(session)
-5
View File
@@ -223,11 +223,6 @@ def make_celery() -> Celery:
"schedule": 86400.0, # auto-tag wip/editor process art (#1464); "schedule": 86400.0, # auto-tag wip/editor process art (#1464);
# no-op unless process_auto_apply_enabled (opt-in) # no-op unless process_auto_apply_enabled (opt-in)
}, },
"soft-wip-conflict-audit-daily": {
"task": "backend.app.tasks.ml.scheduled_soft_wip_conflict_audit",
"schedule": 86400.0, # flag ring-loud soft-WIP (sketch/doodle) tags
# for review (#1474); no-op with no content heads
},
"prune-presentation-reviews-daily": { "prune-presentation-reviews-daily": {
"task": "backend.app.tasks.ml.prune_presentation_reviews", "task": "backend.app.tasks.ml.prune_presentation_reviews",
"schedule": 86400.0, # retention: drop resolved review flags >30d "schedule": 86400.0, # retention: drop resolved review flags >30d
-7
View File
@@ -238,13 +238,6 @@ class ImportSettings(Base):
wip_title_tagging_enabled: Mapped[bool] = mapped_column( wip_title_tagging_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true", Boolean, nullable=False, default=True, server_default="true",
) )
# Soft WIP title tier (#1474): also tag sketch/doodle/scribble titles, but with
# a PROVISIONAL source (`wip_title_soft`) that never trains the head, since these
# are lower-precision (a finished "sketch" isn't WIP). OFF by default — a lower-
# precision tier is opt-in (the ring-loud audit surfaces false positives).
wip_soft_title_tagging_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false",
)
@classmethod @classmethod
async def load(cls, session) -> ImportSettings: async def load(cls, session) -> ImportSettings:
+3 -14
View File
@@ -49,10 +49,8 @@ from .audits import single_color
from .link_extract import extract_external_links from .link_extract import extract_external_links
from .thumbnailer import Thumbnailer from .thumbnailer import Thumbnailer
from .wip_title import ( from .wip_title import (
WIP_TITLE_SOFT_SOURCE,
WIP_TITLE_SOURCE, WIP_TITLE_SOURCE,
apply_wip_image_tags, apply_wip_image_tags,
matches_soft_wip_title,
matches_wip_title, matches_wip_title,
resolve_wip_tag_id, resolve_wip_tag_id,
) )
@@ -1040,9 +1038,7 @@ class Importer:
removal sticks. The existing catalogue is covered separately by the removal sticks. The existing catalogue is covered separately by the
operator-triggered backfill sweep. Gated by the settings toggle, and operator-triggered backfill sweep. Gated by the settings toggle, and
best-effort: any failure is logged, never allowed to fail the import.""" best-effort: any failure is logged, never allowed to fail the import."""
hard_on = self.settings.wip_title_tagging_enabled if not self.settings.wip_title_tagging_enabled:
soft_on = self.settings.wip_soft_title_tagging_enabled
if not (hard_on or soft_on):
return return
if record.primary_post_id is None: if record.primary_post_id is None:
return return
@@ -1050,21 +1046,14 @@ class Importer:
title = self.session.execute( title = self.session.execute(
select(Post.post_title).where(Post.id == record.primary_post_id) select(Post.post_title).where(Post.id == record.primary_post_id)
).scalar_one_or_none() ).scalar_one_or_none()
# HARD tier ("WIP"/"work in progress") wins — higher precision, and it if not matches_wip_title(title):
# trains the head; SOFT (sketch/doodle, #1474) is the provisional fallback
# that never trains (source wip_title_soft).
if hard_on and matches_wip_title(title):
source = WIP_TITLE_SOURCE
elif soft_on and matches_soft_wip_title(title):
source = WIP_TITLE_SOFT_SOURCE
else:
return return
if self._wip_tag_id is _UNSET: if self._wip_tag_id is _UNSET:
self._wip_tag_id = resolve_wip_tag_id(self.session) self._wip_tag_id = resolve_wip_tag_id(self.session)
if self._wip_tag_id is None: if self._wip_tag_id is None:
return return
apply_wip_image_tags( apply_wip_image_tags(
self.session, [record.id], self._wip_tag_id, source=source self.session, [record.id], self._wip_tag_id, source=WIP_TITLE_SOURCE
) )
except Exception as exc: # noqa: BLE001 — a tag must never fail an import except Exception as exc: # noqa: BLE001 — a tag must never fail an import
log.warning( log.warning(
+5 -68
View File
@@ -97,8 +97,8 @@ def _sigmoid(z, np):
def _conflict_scores(Xn, Wc, bc, np): def _conflict_scores(Xn, Wc, bc, np):
"""The presentation conflict signal (#141): per row, the MAX content-head """The presentation conflict signal (#141): per row, the MAX content-head
probability and WHICH head produced it. Shared by the system-tag sweep's guard-2 probability and WHICH head produced it — the system-tag sweep's guard-2 asks
and the soft-wip audit — both ask "does this ALSO look like real content?".""" "does this ALSO look like real content?"."""
cprobs = _sigmoid(Xn @ Wc.T + bc, np) cprobs = _sigmoid(Xn @ Wc.T + bc, np)
return cprobs.max(axis=1), cprobs.argmax(axis=1) return cprobs.max(axis=1), cprobs.argmax(axis=1)
@@ -106,10 +106,9 @@ def _conflict_scores(Xn, Wc, bc, np):
def _insert_presentation_review( def _insert_presentation_review(
session, *, image_record_id, tag_id, conflict_tag_id, conflict_score, mode, session, *, image_record_id, tag_id, conflict_tag_id, conflict_score, mode,
): ):
"""Single-source the ring-loud PresentationReview row shape so the two writers """Single-source the ring-loud PresentationReview row shape, so every writer of
(system-tag sweep guard-2 + soft-wip audit) can't drift on columns or `mode` — the (image_record_id, tag_id) composite PK agrees on columns and `mode` — a
they share the (image_record_id, tag_id) composite PK, so a divergent `mode` divergent `mode` would be a silent first-writer-wins bug."""
would be a silent first-writer-wins bug."""
session.execute( session.execute(
pg_insert(PresentationReview) pg_insert(PresentationReview)
.values( .values(
@@ -963,68 +962,6 @@ def system_tag_auto_apply_sweep(
} }
def soft_wip_conflict_audit(session: Session, dry_run: bool = False) -> dict:
"""Ring-loud audit for the SOFT WIP-title cohort (#1474). Images auto-tagged
`wip` from a low-precision sketch/doodle title (source='wip_title_soft') that ALSO
score >= the process conflict threshold on a content head are probably FINISHED
art mis-tagged as process — flag them (PresentationReview, mode='process') so the
review strip surfaces them ("also looks like <X>", Keep tag / Remove tag). Does
NOT remove the tag; the operator decides. No-op when there are no content heads.
numpy-only. Returns {n_scanned, n_flagged}."""
import numpy as np
from ..wip_title import WIP_TITLE_SOFT_SOURCE, resolve_wip_tag_id
settings = _settings(session)
ver = settings.embedder_model_version
conflict_thr = float(settings.process_conflict_threshold)
conf = _conflict_heads(session, ver)
wip_id = resolve_wip_tag_id(session)
if not conf or wip_id is None:
return {"n_scanned": 0, "n_flagged": 0}
Wc = np.vstack([np.asarray(r.weights, dtype=np.float32) for r in conf])
bc = np.asarray([r.bias for r in conf], dtype=np.float32)
conf_tag_ids = [r.tag_id for r in conf]
soft_ids = [iid for (iid,) in session.execute(
select(image_tag.c.image_record_id)
.where(image_tag.c.tag_id == wip_id)
.where(image_tag.c.source == WIP_TITLE_SOFT_SOURCE)
)]
# Skip images already flagged for this tag (idempotent re-runs).
flagged = {iid for (iid,) in session.execute(
select(PresentationReview.image_record_id)
.where(PresentationReview.tag_id == wip_id)
)}
soft_ids = [i for i in soft_ids if i not in flagged]
n_flagged = 0
scanned = 0
for start in range(0, len(soft_ids), _AUTO_APPLY_CHUNK):
chunk = soft_ids[start:start + _AUTO_APPLY_CHUNK]
emb = _load_embeddings(session, chunk)
cids = [i for i in chunk if i in emb]
if not cids:
continue
scanned += len(cids)
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
max_c, arg_c = _conflict_scores(Xn, Wc, bc, np)
for k in range(len(cids)):
if float(max_c[k]) >= conflict_thr:
n_flagged += 1
if not dry_run:
_insert_presentation_review(
session,
image_record_id=cids[k], tag_id=wip_id,
conflict_tag_id=conf_tag_ids[int(arg_c[k])],
conflict_score=float(max_c[k]),
mode="process",
)
if not dry_run:
session.commit()
return {"n_scanned": scanned, "n_flagged": n_flagged}
def retract_auto_applied_heads(session: Session) -> int: def retract_auto_applied_heads(session: Session) -> int:
"""Soft auto-apply (milestone 139): re-score every standing source='head_auto' """Soft auto-apply (milestone 139): re-score every standing source='head_auto'
tag against its CURRENT head and REMOVE the ones now BELOW the head's tag against its CURRENT head and REMOVE the ones now BELOW the head's
-3
View File
@@ -32,11 +32,8 @@ from ...models.tag import image_tag
# `process_auto` (#1464): wip/editor screenshot applied by the process sweep are # `process_auto` (#1464): wip/editor screenshot applied by the process sweep are
# ALSO provisional — the head must learn only from title (`wip_title`) + manual # 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). # labels, never its own auto-applied output, or it would runaway (operator 2026-07-12).
# `wip_title_soft` (#1474): the soft title tier (sketch/doodle) is LOW-precision, so
# it's provisional too — a finished piece titled "sketch" must not train the wip head.
_AUTO_SOURCES = ( _AUTO_SOURCES = (
"head_auto", "ccip_auto", "ml_auto", "presentation_auto", "process_auto", "head_auto", "ccip_auto", "ml_auto", "presentation_auto", "process_auto",
"wip_title_soft",
) )
+6 -26
View File
@@ -27,13 +27,10 @@ from .image_tag_apply import insert_image_tags
# image_tag.source stamped on title-heuristic WIP tags — distinct from the other # image_tag.source stamped on title-heuristic WIP tags — distinct from the other
# apply sources so provenance stays legible and a future undo can target only these. # apply sources so provenance stays legible and a future undo can target only these.
# HARD tier ("WIP"/"work in progress") is high-precision → trains the wip head. # Only the artist's own "WIP"/"work in progress" label counts — high-precision, so it
# trains the wip head. A sketch/doodle/scribble tier (#1474) was retired in milestone
# 430: a "sketch" is usually finished art, and its 6k tags flooded the review strip.
WIP_TITLE_SOURCE = "wip_title" WIP_TITLE_SOURCE = "wip_title"
# SOFT tier (sketch/doodle/scribble, #1474) is LOWER-precision — a finished "sketch"
# is often not WIP. This source is PROVISIONAL (in training_data._AUTO_SOURCES) so it
# NEVER trains the wip head; a soft-tagged image that also looks like real content is
# surfaced by the ring-loud audit for review.
WIP_TITLE_SOFT_SOURCE = "wip_title_soft"
# A standalone "WIP" / "W.I.P" token, or the phrase "work in progress" # A standalone "WIP" / "W.I.P" token, or the phrase "work in progress"
# (space/underscore/hyphen separated). The letter-boundary lookarounds are what # (space/underscore/hyphen separated). The letter-boundary lookarounds are what
@@ -45,20 +42,10 @@ _WIP_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# Soft tier: sketch / doodle / scribble (+ plurals), letter-boundary anchored so # Coarse SQL prefilter for the backfill sweep — narrows the post scan to rows that
# "sketchbook" / "kadoodle" don't trip it. Deliberately conservative — recall is
# secondary because the soft source doesn't train the head and the ring-loud audit
# catches false positives.
_SOFT_WIP_RE = re.compile(
r"(?<![A-Za-z])(?:sketch|sketches|doodle|doodles|scribble|scribbles)(?![A-Za-z])",
re.IGNORECASE,
)
# Coarse SQL prefilters for the backfill sweep — narrow the post scan to rows that
# COULD match before the precise regex confirms. Case-insensitive ILIKE patterns. # COULD match before the precise regex confirms. Case-insensitive ILIKE patterns.
# Each MUST stay a SUPERSET of its regex or the sweep would silently miss posts. # It MUST stay a SUPERSET of the regex or the sweep would silently miss posts.
WIP_TITLE_SQL_PREFILTER = ("%wip%", "%work%progress%") WIP_TITLE_SQL_PREFILTER = ("%wip%", "%work%progress%")
SOFT_WIP_TITLE_SQL_PREFILTER = ("%sketch%", "%doodle%", "%scribble%")
# Chunk bulk inserts so a large sweep can't blow past psycopg's 65535-parameter # Chunk bulk inserts so a large sweep can't blow past psycopg's 65535-parameter
# ceiling (3 params/row → ~21k rows max; 5k stays comfortably under). # ceiling (3 params/row → ~21k rows max; 5k stays comfortably under).
@@ -66,19 +53,12 @@ _INSERT_CHUNK = 5000
def matches_wip_title(title: str | None) -> bool: def matches_wip_title(title: str | None) -> bool:
"""True when a post title explicitly marks it work-in-progress (HARD tier).""" """True when a post title explicitly marks it work-in-progress."""
if not title: if not title:
return False return False
return _WIP_RE.search(title) is not None return _WIP_RE.search(title) is not None
def matches_soft_wip_title(title: str | None) -> bool:
"""True when a title carries a SOFT WIP cue (sketch/doodle/scribble, #1474)."""
if not title:
return False
return _SOFT_WIP_RE.search(title) is not None
def resolve_wip_tag_id(session: Session) -> int | None: def resolve_wip_tag_id(session: Session) -> int | None:
"""The seeded ``wip`` system tag's id (migration 0075), or None if absent.""" """The seeded ``wip`` system tag's id (migration 0075), or None if absent."""
return session.execute( return session.execute(
+4 -20
View File
@@ -1041,8 +1041,7 @@ def cleanup_old_download_events() -> int:
def _backfill_wip_tier(session, tag_id, prefilter, matcher, source) -> int: def _backfill_wip_tier(session, tag_id, prefilter, matcher, source) -> int:
"""One keyset-paginated pass over posts whose title matches a WIP tier, applying """One keyset-paginated pass over posts whose title matches a WIP tier, applying
`tag_id` (stamped `source`) to their images. Shared by the hard + soft tiers `tag_id` (stamped `source`) to their images (#1458). Coarse `prefilter` (ILIKE superset) narrows the scan; the precise
(#1458 / #1474). Coarse `prefilter` (ILIKE superset) narrows the scan; the precise
`matcher` confirms. Idempotent-additive (ON CONFLICT DO NOTHING). Returns the row `matcher` confirms. Idempotent-additive (ON CONFLICT DO NOTHING). Returns the row
count newly applied.""" count newly applied."""
from ..models import Post from ..models import Post
@@ -1082,26 +1081,17 @@ def _backfill_wip_tier(session, tag_id, prefilter, matcher, source) -> int:
) )
def backfill_wip_title_tags() -> int: def backfill_wip_title_tags() -> int:
"""Scan EXISTING posts for WIP titles and apply the `wip` system tag to their """Scan EXISTING posts for WIP titles and apply the `wip` system tag to their
images — the operator-triggered back-catalogue catch-up (task #1458 hard tier + images — the operator-triggered back-catalogue catch-up (task #1458). New
#1474 soft tier). New imports are tagged live by the importer; this covers the imports are tagged live by the importer; this covers the existing library.
existing library. Keyset-paginated, restart-safe.
HARD tier ("WIP"/"work in progress") always runs (the operator triggered the
scan); the SOFT tier (sketch/doodle, provisional source) runs only when
wip_soft_title_tagging_enabled, AFTER hard so a title matching both keeps the
trained hard tag (ON CONFLICT DO NOTHING). Keyset-paginated, restart-safe.
Deliberately NOT scheduled as a beat: a periodic re-run would re-apply to matching Deliberately NOT scheduled as a beat: a periodic re-run would re-apply to matching
posts and silently undo a manual WIP removal, so it stays an explicit operator posts and silently undo a manual WIP removal, so it stays an explicit operator
action (Settings → "Scan existing posts for WIP titles"). Returns rows applied. action (Settings → "Scan existing posts for WIP titles"). Returns rows applied.
""" """
from ..models import ImportSettings
from ..services.wip_title import ( from ..services.wip_title import (
SOFT_WIP_TITLE_SQL_PREFILTER,
WIP_TITLE_SOFT_SOURCE,
WIP_TITLE_SOURCE, WIP_TITLE_SOURCE,
WIP_TITLE_SQL_PREFILTER, WIP_TITLE_SQL_PREFILTER,
matches_soft_wip_title,
matches_wip_title, matches_wip_title,
resolve_wip_tag_id, resolve_wip_tag_id,
) )
@@ -1114,16 +1104,10 @@ def backfill_wip_title_tags() -> int:
"backfill_wip_title_tags: no `wip` system tag present; nothing to do" "backfill_wip_title_tags: no `wip` system tag present; nothing to do"
) )
return 0 return 0
settings = ImportSettings.load_sync(session)
applied = _backfill_wip_tier( applied = _backfill_wip_tier(
session, tag_id, WIP_TITLE_SQL_PREFILTER, matches_wip_title, session, tag_id, WIP_TITLE_SQL_PREFILTER, matches_wip_title,
WIP_TITLE_SOURCE, WIP_TITLE_SOURCE,
) )
if settings.wip_soft_title_tagging_enabled:
applied += _backfill_wip_tier(
session, tag_id, SOFT_WIP_TITLE_SQL_PREFILTER, matches_soft_wip_title,
WIP_TITLE_SOFT_SOURCE,
)
if applied: if applied:
log.info("backfill_wip_title_tags: applied wip to %d image(s)", applied) log.info("backfill_wip_title_tags: applied wip to %d image(s)", applied)
return applied return applied
-18
View File
@@ -620,24 +620,6 @@ def scheduled_process_auto_apply() -> str:
return f"applied={result['n_applied']} flagged={result['n_flagged']}" return f"applied={result['n_applied']} flagged={result['n_flagged']}"
@celery.task(
name="backend.app.tasks.ml.scheduled_soft_wip_conflict_audit",
soft_time_limit=1800, time_limit=2100,
)
def scheduled_soft_wip_conflict_audit() -> str:
"""Ring-loud audit over the SOFT WIP-title cohort (#1474) — flag sketch/doodle
auto-tags that ALSO look like real content for review. No-op when there are no
content heads; idempotent (already-flagged images skipped). Runs regardless of
the process-sweep toggle, since soft-title tags come from the importer, not that
sweep. Wall-clock bounded by the task time limits."""
from ..services.ml.heads import soft_wip_conflict_audit
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
result = soft_wip_conflict_audit(session)
return f"scanned={result['n_scanned']} flagged={result['n_flagged']}"
@celery.task(name="backend.app.tasks.ml.prune_presentation_reviews") @celery.task(name="backend.app.tasks.ml.prune_presentation_reviews")
def prune_presentation_reviews() -> str: def prune_presentation_reviews() -> str:
"""Retention (rule 89): drop RESOLVED presentation-review flags older than 30 """Retention (rule 89): drop RESOLVED presentation-review flags older than 30
@@ -2,14 +2,14 @@
<!-- System-tag auto-applies (chrome hides / process WIP tags) that ALSO looked <!-- System-tag auto-applies (chrome hides / process WIP tags) that ALSO looked
like real content — surfaced PROACTIVELY atop the gallery whenever there's like real content — surfaced PROACTIVELY atop the gallery whenever there's
something to review (NOT gated on the Show-hidden toggle, so misfires can't something to review (NOT gated on the Show-hidden toggle, so misfires can't
go unnoticed), most-concerning first, with keep / remove (#141, #1464). go unnoticed), most-concerning first (#141, #1464). Each card asks one
question — is this image a <tag>? — and its buttons answer it (#4424).
Renders nothing when there's nothing to review. --> Renders nothing when there's nothing to review. -->
<section v-if="items.length" class="fc-review" aria-label="Auto-tagged images to review"> <section v-if="items.length" class="fc-review" aria-label="Auto-tagged images to review">
<div class="fc-review__head"> <div class="fc-review__head">
<v-icon size="18" color="warning">mdi-alert-outline</v-icon> <v-icon size="18" color="warning">mdi-alert-outline</v-icon>
<span class="fc-review__title"> <span class="fc-review__title">
{{ items.length }} auto-tagged {{ items.length === 1 ? 'image' : 'images' }} {{ items.length }} {{ items.length === 1 ? 'auto-tag' : 'auto-tags' }} to check
may be real content — review
</span> </span>
</div> </div>
<div class="fc-review__cards"> <div class="fc-review__cards">
@@ -21,22 +21,23 @@
class="fc-review-card__thumb" loading="lazy" class="fc-review-card__thumb" loading="lazy"
> >
<div class="fc-review-card__body"> <div class="fc-review-card__body">
<div class="fc-review-card__question">{{ question(it) }}</div>
<div <div
class="fc-review-card__conflict" class="fc-review-card__reason"
:title="`Scored ${Math.round(it.conflict_score * 100)}% on “${it.conflict_name || 'a content tag'}”`" :title="reasonTitle(it)"
> >
also looks like <strong>{{ it.conflict_name || 'content' }}</strong> also {{ Math.round(it.conflict_score * 100) }}%
<strong>{{ it.conflict_name || 'content' }}</strong>
</div> </div>
<div class="fc-review-card__tag">{{ tagLine(it) }}</div>
<div class="fc-review-card__acts"> <div class="fc-review-card__acts">
<button <button
type="button" class="fc-review-btn fc-review-btn--keep" type="button" class="fc-review-btn fc-review-btn--keep"
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'keep')" :disabled="busy.includes(keyOf(it))" @click="resolve(it, 'keep')"
>{{ keepLabel(it) }}</button> >Is {{ withArticle(it) }}</button>
<button <button
type="button" class="fc-review-btn fc-review-btn--unhide" type="button" class="fc-review-btn fc-review-btn--unhide"
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'unhide')" :disabled="busy.includes(keyOf(it))" @click="resolve(it, 'unhide')"
>{{ removeLabel(it) }}</button> >Is not {{ withArticle(it) }}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -55,11 +56,18 @@ const items = ref([])
const busy = ref([]) const busy = ref([])
function keyOf(it) { return `${it.image_id}:${it.tag_id}` } function keyOf(it) { return `${it.image_id}:${it.tag_id}` }
// Chrome flags hide the image (keep-hidden / un-hide); process flags leave it // The card asks whether the image IS the auto-applied system tag, and the buttons
// visible and just tagged (keep-tag / remove-tag). Same endpoints, different words. // answer that (operator, #4424: "is a <tag>" / "is not a <tag>"). "Is" keeps the
function tagLine(it) { return (it.mode === 'process' ? 'auto-tagged ' : 'hidden as ') + it.tag_name } // tag ('keep'); "Is not" removes it, un-hiding a chrome image ('unhide'). The
function keepLabel(it) { return it.mode === 'process' ? 'Keep tag' : 'Keep hidden' } // content tag it also scored on is the reason it was flagged, not the question.
function removeLabel(it) { return it.mode === 'process' ? 'Remove tag' : 'Un-hide' } function noun(it) { return it.tag_name === 'wip' ? 'WIP' : it.tag_name }
function withArticle(it) { return (/^[aeiou]/i.test(noun(it)) ? 'an ' : 'a ') + noun(it) }
function question(it) { return `Is this ${withArticle(it)}?` }
function reasonTitle(it) {
const hidden = it.mode === 'process' ? '' : ' It is hidden from the gallery until you answer.'
return `Auto-tagged “${it.tag_name}”, but it also scored ${Math.round(it.conflict_score * 100)}% `
+ `on “${it.conflict_name || 'a content tag'}”, so it may be finished art.${hidden}`
}
async function load() { async function load() {
// Fetched unconditionally on mount — the strip prompts for pending misfires // Fetched unconditionally on mount — the strip prompts for pending misfires
@@ -77,14 +85,11 @@ async function resolve(it, action) {
await api.post(`/api/gallery/hidden-review/${it.image_id}/${it.tag_id}/${action}`) await api.post(`/api/gallery/hidden-review/${it.image_id}/${it.tag_id}/${action}`)
items.value = items.value.filter((x) => keyOf(x) !== k) items.value = items.value.filter((x) => keyOf(x) !== k)
if (action === 'unhide') { if (action === 'unhide') {
const verb = it.mode === 'process' ? 'Removed' : 'Un-hidden' const shown = it.mode === 'process' ? '' : ', back in the gallery'
toast({ text: `${verb} — “${it.tag_name}” removed; it'll train the head`, type: 'success' }) toast({ text: `Not ${withArticle(it)} — “${it.tag_name}” removed${shown}; the tagger learns from it`, type: 'success' })
} }
} catch (e) { } catch (e) {
toast({ toast({ text: `Could not save your answer: ${e.message}`, type: 'error' })
text: `Could not ${action === 'keep' ? 'keep hidden' : 'un-hide'}: ${e.message}`,
type: 'error',
})
} finally { } finally {
busy.value = busy.value.filter((x) => x !== k) busy.value = busy.value.filter((x) => x !== k)
} }
@@ -112,7 +117,7 @@ onMounted(load)
display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px;
} }
.fc-review-card { .fc-review-card {
flex: 0 0 auto; width: 150px; flex: 0 0 auto; width: 170px;
display: flex; flex-direction: column; display: flex; flex-direction: column;
border: 1px solid rgb(var(--v-theme-surface-light)); border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 6px; overflow: hidden; border-radius: 6px; overflow: hidden;
@@ -123,16 +128,16 @@ onMounted(load)
background: rgb(var(--v-theme-surface-light)); background: rgb(var(--v-theme-surface-light));
} }
.fc-review-card__body { padding: 6px 8px; } .fc-review-card__body { padding: 6px 8px; }
.fc-review-card__conflict { .fc-review-card__question {
font-size: 11px; color: rgb(var(--v-theme-on-surface)); font-size: 12px; font-weight: 600; color: rgb(var(--v-theme-on-surface));
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.fc-review-card__conflict strong { color: rgb(var(--v-theme-warning)); } .fc-review-card__reason {
.fc-review-card__tag {
font-size: 10px; color: rgb(var(--v-theme-on-surface-variant)); font-size: 10px; color: rgb(var(--v-theme-on-surface-variant));
margin: 1px 0 6px; margin: 1px 0 6px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.fc-review-card__reason strong { color: rgb(var(--v-theme-warning)); font-weight: 600; }
.fc-review-card__acts { display: flex; gap: 4px; } .fc-review-card__acts { display: flex; gap: 4px; }
.fc-review-btn { .fc-review-btn {
flex: 1; font-size: 11px; padding: 3px 4px; border-radius: 4px; flex: 1; font-size: 11px; padding: 3px 4px; border-radius: 4px;
@@ -103,17 +103,6 @@
the Explore browse. Applies to new imports; run the scan below to catch the Explore browse. Applies to new imports; run the scan below to catch
posts already in your library. posts already in your library.
</div> </div>
<v-switch
v-model="local.wip_soft_title_tagging_enabled"
label="Also tag “sketch” / “doodle” titles (lower precision)"
density="compact" hide-details color="primary" @change="save"
/>
<div class="fc-help mb-3">
Extends the above to softer cues (<code>sketch</code>, <code>doodle</code>,
<code>scribble</code>). These stay <strong>visible</strong> and never train
the tagging model — a daily audit flags any that actually look like finished
art for review. Off by default.
</div>
<v-btn <v-btn
variant="tonal" color="primary" size="small" variant="tonal" color="primary" size="small"
:loading="store.wipScanBusy" prepend-icon="mdi-magnify" :loading="store.wipScanBusy" prepend-icon="mdi-magnify"
@@ -156,7 +145,6 @@ const local = reactive({
skip_single_color: false, single_color_threshold: 0.95, skip_single_color: false, single_color_threshold: 0.95,
phash_threshold: 24, phash_threshold: 24,
wip_title_tagging_enabled: true, wip_title_tagging_enabled: true,
wip_soft_title_tagging_enabled: false,
}) })
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true }) watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
+3 -39
View File
@@ -126,53 +126,17 @@ def test_process_sweep_flags_conflict_with_process_mode(db_sync):
def test_process_auto_source_never_trains_head(db_sync): def test_process_auto_source_never_trains_head(db_sync):
# The runaway break: provisional wip tags (process sweep 'process_auto', soft # The runaway break: provisional wip tags (process sweep 'process_auto') are NOT
# title 'wip_title_soft') are NOT training positives; a HARD title-heuristic / # training positives; a title-heuristic / manual one IS. So the head learns only
# manual one IS. So the head learns only from trusted labels, never its own # from trusted labels, never its own output (#1464).
# output or the low-precision sketch/doodle tier (#1464 + #1474).
wip = _system_tag(db_sync, "wip") wip = _system_tag(db_sync, "wip")
auto_img = _img(db_sync, "f" * 64, _emb(0)) auto_img = _img(db_sync, "f" * 64, _emb(0))
soft_img = _img(db_sync, "9" * 64, _emb(2))
title_img = _img(db_sync, "0" * 64, _emb(1)) title_img = _img(db_sync, "0" * 64, _emb(1))
db_sync.execute(image_tag.insert().values( db_sync.execute(image_tag.insert().values(
image_record_id=auto_img.id, tag_id=wip.id, source="process_auto")) image_record_id=auto_img.id, tag_id=wip.id, source="process_auto"))
db_sync.execute(image_tag.insert().values(
image_record_id=soft_img.id, tag_id=wip.id, source="wip_title_soft"))
db_sync.execute(image_tag.insert().values( db_sync.execute(image_tag.insert().values(
image_record_id=title_img.id, tag_id=wip.id, source="wip_title")) image_record_id=title_img.id, tag_id=wip.id, source="wip_title"))
db_sync.commit() db_sync.commit()
positives = set(_ids_with_tag(db_sync, wip.id)) positives = set(_ids_with_tag(db_sync, wip.id))
assert title_img.id in positives # trusted HARD label trains the head assert title_img.id in positives # trusted HARD label trains the head
assert auto_img.id not in positives # its own auto-applied output does NOT assert auto_img.id not in positives # its own auto-applied output does NOT
assert soft_img.id not in positives # low-precision soft tier does NOT
def test_soft_wip_conflict_audit_flags_ring_loud(db_sync):
# A soft-tagged image (sketch/doodle title) that ALSO scores high on a content
# head is probably finished art mis-tagged — flagged for review; a quiet one is not.
from backend.app.services.ml.heads import soft_wip_conflict_audit
s = db_sync.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one()
s.process_conflict_threshold = 0.6
wip = _system_tag(db_sync, "wip")
content = Tag(name="looksreal", kind=TagKind.general)
db_sync.add(content)
db_sync.flush()
_head(db_sync, content.id, 0, weight=1.0) # sigmoid(1)=0.73 > 0.6 conflict
ring = _img(db_sync, "1" * 64, _emb(0)) # scores on the content head
quiet = _img(db_sync, "2" * 64, _emb(5)) # orthogonal → 0.5 < 0.6
for img in (ring, quiet):
db_sync.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=wip.id, source="wip_title_soft"))
db_sync.commit()
res = soft_wip_conflict_audit(db_sync)
assert res["n_flagged"] == 1
flag = db_sync.execute(
select(PresentationReview).where(PresentationReview.image_record_id == ring.id)
).scalar_one()
assert flag.mode == "process"
assert flag.conflict_tag_id == content.id
assert db_sync.execute(
select(PresentationReview).where(PresentationReview.image_record_id == quiet.id)
).scalar_one_or_none() is None
+90
View File
@@ -0,0 +1,90 @@
"""Migration 0112 (milestone 430, #4428): retiring the sketch/doodle WIP title tier.
Runs the migration's data step against real rows. The soft tags the operator stood
behind (confirmed, or kept through the review strip) survive as `manual`; the rest go,
and so do the unresolved review cards whose tag went with them. Hard title tags and
their flags are untouched.
"""
import importlib.util
from datetime import UTC, datetime
from pathlib import Path
import pytest
from sqlalchemy import select
from backend.app.models import PresentationReview, TagPositiveConfirmation
from backend.app.models.tag import image_tag
from backend.app.services.wip_title import resolve_wip_tag_id
from tests.factories import make_image as _img
pytestmark = pytest.mark.integration
_MIGRATION = (
Path(__file__).resolve().parents[1]
/ "alembic" / "versions" / "0112_retire_soft_wip_title.py"
)
def _retire():
spec = importlib.util.spec_from_file_location("m0112", _MIGRATION)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.retire_soft_wip_tags
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 _review(db, image_id, tag_id):
return db.execute(
select(PresentationReview)
.where(PresentationReview.image_record_id == image_id)
.where(PresentationReview.tag_id == tag_id)
).scalar_one_or_none()
def _flag(db, image_id, tag_id, *, resolved):
db.add(PresentationReview(
image_record_id=image_id, tag_id=tag_id, conflict_score=0.8, mode="process",
resolved_at=datetime.now(UTC) if resolved else None,
))
def test_retire_soft_wip_keeps_human_judged_and_clears_the_rest(db_sync):
wip = resolve_wip_tag_id(db_sync)
plain = _img(db_sync, "a" * 64) # soft, never looked at → removed
confirmed = _img(db_sync, "b" * 64) # soft + confirmed → kept as manual
kept = _img(db_sync, "c" * 64) # soft + "Keep tag" in the strip → manual
pending = _img(db_sync, "d" * 64) # soft + open card → tag and card removed
hard = _img(db_sync, "e" * 64) # hard title tag + open card → untouched
for img in (plain, confirmed, kept, pending):
db_sync.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=wip, source="wip_title_soft"))
db_sync.execute(image_tag.insert().values(
image_record_id=hard.id, tag_id=wip, source="wip_title"))
db_sync.add(TagPositiveConfirmation(image_record_id=confirmed.id, tag_id=wip))
_flag(db_sync, kept.id, wip, resolved=True)
_flag(db_sync, pending.id, wip, resolved=False)
_flag(db_sync, hard.id, wip, resolved=False)
db_sync.flush()
_retire()(db_sync.connection())
db_sync.expire_all()
assert _source(db_sync, plain.id, wip) is None
assert _source(db_sync, confirmed.id, wip) == "manual"
assert _source(db_sync, kept.id, wip) == "manual"
assert _source(db_sync, pending.id, wip) is None
assert _source(db_sync, hard.id, wip) == "wip_title"
assert _review(db_sync, pending.id, wip) is None
assert _review(db_sync, kept.id, wip) is not None # resolved history stays
assert _review(db_sync, hard.id, wip) is not None # its tag is still on
assert db_sync.execute(
select(image_tag.c.image_record_id).where(image_tag.c.source == "wip_title_soft")
).first() is None
+1 -27
View File
@@ -8,7 +8,7 @@ negative cases (substrings like ``swipe`` / ``wiped``) are the load-bearing ones
""" """
import pytest import pytest
from backend.app.services.wip_title import matches_soft_wip_title, matches_wip_title from backend.app.services.wip_title import matches_wip_title
@pytest.mark.parametrize("title", [ @pytest.mark.parametrize("title", [
@@ -48,29 +48,3 @@ def test_matches_positive(title):
]) ])
def test_matches_negative(title): def test_matches_negative(title):
assert matches_wip_title(title) is False assert matches_wip_title(title) is False
@pytest.mark.parametrize("title", [
"quick sketch",
"Nami sketch",
"morning doodle",
"some doodles",
"sketches from today",
"a little scribble",
"SKETCH",
])
def test_soft_matches_positive(title):
assert matches_soft_wip_title(title) is True
@pytest.mark.parametrize("title", [
None,
"",
"sketchbook tour", # 'sketch' inside sketchbook — must NOT match
"kadoodle mascot", # 'doodle' mid-word
"the final piece",
"WIP", # a HARD cue is not a SOFT cue
"prescribed colours", # 'scrib' inside prescribed — must NOT match
])
def test_soft_matches_negative(title):
assert matches_soft_wip_title(title) is False
-12
View File
@@ -10,7 +10,6 @@ from backend.app.celery_app import celery
from backend.app.models import Artist, ImageProvenance, ImageRecord, Post, Source from backend.app.models import Artist, ImageProvenance, ImageRecord, Post, Source
from backend.app.models.tag import image_tag from backend.app.models.tag import image_tag
from backend.app.services.wip_title import ( from backend.app.services.wip_title import (
WIP_TITLE_SOFT_SOURCE,
apply_wip_image_tags, apply_wip_image_tags,
resolve_wip_tag_id, resolve_wip_tag_id,
) )
@@ -108,14 +107,3 @@ def test_backfill_tags_only_wip_titled_posts(db_sync):
# Idempotent: a second sweep finds the tag already present and applies nothing. # Idempotent: a second sweep finds the tag already present and applies nothing.
assert backfill_wip_title_tags.apply().get() == 0 assert backfill_wip_title_tags.apply().get() == 0
def test_apply_soft_source_stamps_wip_title_soft(db_sync):
# The soft tier (#1474) stamps a distinct provisional source.
tag_id = resolve_wip_tag_id(db_sync)
rec = _img(db_sync)
db_sync.commit()
assert apply_wip_image_tags(
db_sync, [rec.id], tag_id, source=WIP_TITLE_SOFT_SOURCE
) == 1
assert _wip_source(db_sync, rec.id, tag_id) == "wip_title_soft"