diff --git a/alembic/versions/0112_retire_soft_wip_title.py b/alembic/versions/0112_retire_soft_wip_title.py new file mode 100644 index 0000000..0763d21 --- /dev/null +++ b/alembic/versions/0112_retire_soft_wip_title.py @@ -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"), + ), + ) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index c8bc828..73f8422 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -57,7 +57,6 @@ _EDITABLE_FIELDS = ( "translation_target_lang", "translation_min_confidence", "wip_title_tagging_enabled", - "wip_soft_title_tagging_enabled", ) # Per-host external-download toggles — all plain booleans, validated uniformly. @@ -196,12 +195,6 @@ async def update_import_settings(): return jsonify( {"error": "wip_title_tagging_enabled must be a boolean"} ), 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: row = await ImportSettings.load(session) diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index bd0ad2c..6843589 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -223,11 +223,6 @@ def make_celery() -> Celery: "schedule": 86400.0, # auto-tag wip/editor process art (#1464); # 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": { "task": "backend.app.tasks.ml.prune_presentation_reviews", "schedule": 86400.0, # retention: drop resolved review flags >30d diff --git a/backend/app/models/import_settings.py b/backend/app/models/import_settings.py index 3a0bba2..eb340d0 100644 --- a/backend/app/models/import_settings.py +++ b/backend/app/models/import_settings.py @@ -238,13 +238,6 @@ class ImportSettings(Base): wip_title_tagging_enabled: Mapped[bool] = mapped_column( 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 async def load(cls, session) -> ImportSettings: diff --git a/backend/app/services/importer.py b/backend/app/services/importer.py index bd0e6ea..a7edb5f 100644 --- a/backend/app/services/importer.py +++ b/backend/app/services/importer.py @@ -49,10 +49,8 @@ from .audits import single_color from .link_extract import extract_external_links from .thumbnailer import Thumbnailer from .wip_title import ( - WIP_TITLE_SOFT_SOURCE, WIP_TITLE_SOURCE, apply_wip_image_tags, - matches_soft_wip_title, matches_wip_title, resolve_wip_tag_id, ) @@ -1040,9 +1038,7 @@ class Importer: removal sticks. The existing catalogue is covered separately by the operator-triggered backfill sweep. Gated by the settings toggle, and best-effort: any failure is logged, never allowed to fail the import.""" - hard_on = self.settings.wip_title_tagging_enabled - soft_on = self.settings.wip_soft_title_tagging_enabled - if not (hard_on or soft_on): + if not self.settings.wip_title_tagging_enabled: return if record.primary_post_id is None: return @@ -1050,21 +1046,14 @@ class Importer: title = self.session.execute( select(Post.post_title).where(Post.id == record.primary_post_id) ).scalar_one_or_none() - # HARD tier ("WIP"/"work in progress") wins — higher precision, and it - # 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: + if not matches_wip_title(title): return if self._wip_tag_id is _UNSET: self._wip_tag_id = resolve_wip_tag_id(self.session) if self._wip_tag_id is None: return 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 log.warning( diff --git a/backend/app/services/ml/heads.py b/backend/app/services/ml/heads.py index c6d2a02..f752385 100644 --- a/backend/app/services/ml/heads.py +++ b/backend/app/services/ml/heads.py @@ -97,8 +97,8 @@ def _sigmoid(z, np): def _conflict_scores(Xn, Wc, bc, np): """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 - and the soft-wip audit — both ask "does this ALSO look like real content?".""" + probability and WHICH head produced it — the system-tag sweep's guard-2 asks + "does this ALSO look like real content?".""" cprobs = _sigmoid(Xn @ Wc.T + bc, np) return cprobs.max(axis=1), cprobs.argmax(axis=1) @@ -106,10 +106,9 @@ def _conflict_scores(Xn, Wc, bc, np): def _insert_presentation_review( session, *, image_record_id, tag_id, conflict_tag_id, conflict_score, mode, ): - """Single-source the ring-loud PresentationReview row shape so the two writers - (system-tag sweep guard-2 + soft-wip audit) can't drift on columns or `mode` — - they share the (image_record_id, tag_id) composite PK, so a divergent `mode` - would be a silent first-writer-wins bug.""" + """Single-source the ring-loud PresentationReview row shape, so every writer of + the (image_record_id, tag_id) composite PK agrees on columns and `mode` — a + divergent `mode` would be a silent first-writer-wins bug.""" session.execute( pg_insert(PresentationReview) .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 ", 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: """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 diff --git a/backend/app/services/ml/training_data.py b/backend/app/services/ml/training_data.py index bce0e58..5e452c4 100644 --- a/backend/app/services/ml/training_data.py +++ b/backend/app/services/ml/training_data.py @@ -32,11 +32,8 @@ from ...models.tag import image_tag # `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). -# `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 = ( "head_auto", "ccip_auto", "ml_auto", "presentation_auto", "process_auto", - "wip_title_soft", ) diff --git a/backend/app/services/wip_title.py b/backend/app/services/wip_title.py index 8608cab..b901882 100644 --- a/backend/app/services/wip_title.py +++ b/backend/app/services/wip_title.py @@ -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 # 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" -# 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" # (space/underscore/hyphen separated). The letter-boundary lookarounds are what @@ -45,20 +42,10 @@ _WIP_RE = re.compile( re.IGNORECASE, ) -# Soft tier: sketch / doodle / scribble (+ plurals), letter-boundary anchored so -# "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"(? 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: return False 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: """The seeded ``wip`` system tag's id (migration 0075), or None if absent.""" return session.execute( diff --git a/backend/app/tasks/maintenance.py b/backend/app/tasks/maintenance.py index 8b653de..db013ed 100644 --- a/backend/app/tasks/maintenance.py +++ b/backend/app/tasks/maintenance.py @@ -1041,8 +1041,7 @@ def cleanup_old_download_events() -> 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 - `tag_id` (stamped `source`) to their images. Shared by the hard + soft tiers - (#1458 / #1474). Coarse `prefilter` (ILIKE superset) narrows the scan; the precise + `tag_id` (stamped `source`) to their images (#1458). Coarse `prefilter` (ILIKE superset) narrows the scan; the precise `matcher` confirms. Idempotent-additive (ON CONFLICT DO NOTHING). Returns the row count newly applied.""" 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: """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 + - #1474 soft tier). New imports are tagged live by the importer; this covers the - existing library. - - 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. + images — the operator-triggered back-catalogue catch-up (task #1458). New + imports are tagged live by the importer; this covers the existing library. + Keyset-paginated, restart-safe. 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 action (Settings → "Scan existing posts for WIP titles"). Returns rows applied. """ - from ..models import ImportSettings from ..services.wip_title import ( - SOFT_WIP_TITLE_SQL_PREFILTER, - WIP_TITLE_SOFT_SOURCE, WIP_TITLE_SOURCE, WIP_TITLE_SQL_PREFILTER, - matches_soft_wip_title, matches_wip_title, 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" ) return 0 - settings = ImportSettings.load_sync(session) applied = _backfill_wip_tier( session, tag_id, WIP_TITLE_SQL_PREFILTER, matches_wip_title, 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: log.info("backfill_wip_title_tags: applied wip to %d image(s)", applied) return applied diff --git a/backend/app/tasks/ml.py b/backend/app/tasks/ml.py index e21c1aa..7876e94 100644 --- a/backend/app/tasks/ml.py +++ b/backend/app/tasks/ml.py @@ -620,24 +620,6 @@ def scheduled_process_auto_apply() -> str: 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") def prune_presentation_reviews() -> str: """Retention (rule 89): drop RESOLVED presentation-review flags older than 30 diff --git a/frontend/src/components/gallery/HiddenReviewStrip.vue b/frontend/src/components/gallery/HiddenReviewStrip.vue index 8a63786..5ff8384 100644 --- a/frontend/src/components/gallery/HiddenReviewStrip.vue +++ b/frontend/src/components/gallery/HiddenReviewStrip.vue @@ -2,14 +2,14 @@
mdi-alert-outline - {{ items.length }} auto-tagged {{ items.length === 1 ? 'image' : 'images' }} - may be real content — review + {{ items.length }} {{ items.length === 1 ? 'auto-tag' : 'auto-tags' }} to check
@@ -21,22 +21,23 @@ class="fc-review-card__thumb" loading="lazy" >
+
{{ question(it) }}
- also looks like {{ it.conflict_name || 'content' }} + also {{ Math.round(it.conflict_score * 100) }}% + {{ it.conflict_name || 'content' }}
-
{{ tagLine(it) }}
+ >Is {{ withArticle(it) }} + >Is not {{ withArticle(it) }}
@@ -55,11 +56,18 @@ const items = ref([]) const busy = ref([]) function keyOf(it) { return `${it.image_id}:${it.tag_id}` } -// Chrome flags hide the image (keep-hidden / un-hide); process flags leave it -// visible and just tagged (keep-tag / remove-tag). Same endpoints, different words. -function tagLine(it) { return (it.mode === 'process' ? 'auto-tagged ' : 'hidden as ') + it.tag_name } -function keepLabel(it) { return it.mode === 'process' ? 'Keep tag' : 'Keep hidden' } -function removeLabel(it) { return it.mode === 'process' ? 'Remove tag' : 'Un-hide' } +// The card asks whether the image IS the auto-applied system tag, and the buttons +// answer that (operator, #4424: "is a " / "is not a "). "Is" keeps the +// tag ('keep'); "Is not" removes it, un-hiding a chrome image ('unhide'). The +// content tag it also scored on is the reason it was flagged, not the question. +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() { // 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}`) items.value = items.value.filter((x) => keyOf(x) !== k) if (action === 'unhide') { - const verb = it.mode === 'process' ? 'Removed' : 'Un-hidden' - toast({ text: `${verb} — “${it.tag_name}” removed; it'll train the head`, type: 'success' }) + const shown = it.mode === 'process' ? '' : ', back in the gallery' + toast({ text: `Not ${withArticle(it)} — “${it.tag_name}” removed${shown}; the tagger learns from it`, type: 'success' }) } } catch (e) { - toast({ - text: `Could not ${action === 'keep' ? 'keep hidden' : 'un-hide'}: ${e.message}`, - type: 'error', - }) + toast({ text: `Could not save your answer: ${e.message}`, type: 'error' }) } finally { busy.value = busy.value.filter((x) => x !== k) } @@ -112,7 +117,7 @@ onMounted(load) display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; } .fc-review-card { - flex: 0 0 auto; width: 150px; + flex: 0 0 auto; width: 170px; display: flex; flex-direction: column; border: 1px solid rgb(var(--v-theme-surface-light)); border-radius: 6px; overflow: hidden; @@ -123,16 +128,16 @@ onMounted(load) background: rgb(var(--v-theme-surface-light)); } .fc-review-card__body { padding: 6px 8px; } -.fc-review-card__conflict { - font-size: 11px; color: rgb(var(--v-theme-on-surface)); +.fc-review-card__question { + font-size: 12px; font-weight: 600; color: rgb(var(--v-theme-on-surface)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.fc-review-card__conflict strong { color: rgb(var(--v-theme-warning)); } -.fc-review-card__tag { +.fc-review-card__reason { font-size: 10px; color: rgb(var(--v-theme-on-surface-variant)); margin: 1px 0 6px; 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-btn { flex: 1; font-size: 11px; padding: 3px 4px; border-radius: 4px; diff --git a/frontend/src/components/settings/ImportFiltersForm.vue b/frontend/src/components/settings/ImportFiltersForm.vue index 903342f..176def1 100644 --- a/frontend/src/components/settings/ImportFiltersForm.vue +++ b/frontend/src/components/settings/ImportFiltersForm.vue @@ -103,17 +103,6 @@ the Explore browse. Applies to new imports; run the scan below to catch posts already in your library. - -
- Extends the above to softer cues (sketch, doodle, - scribble). These stay visible and never train - the tagging model — a daily audit flags any that actually look like finished - art for review. Off by default. -
store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true }) diff --git a/tests/test_process_auto_apply.py b/tests/test_process_auto_apply.py index b8db16c..1c964f3 100644 --- a/tests/test_process_auto_apply.py +++ b/tests/test_process_auto_apply.py @@ -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): - # The runaway break: provisional wip tags (process sweep 'process_auto', soft - # title 'wip_title_soft') are NOT training positives; a HARD title-heuristic / - # manual one IS. So the head learns only from trusted labels, never its own - # output or the low-precision sketch/doodle tier (#1464 + #1474). + # The runaway break: provisional wip tags (process sweep 'process_auto') are NOT + # training positives; a title-heuristic / manual one IS. So the head learns only + # from trusted labels, never its own output (#1464). wip = _system_tag(db_sync, "wip") 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)) 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=soft_img.id, tag_id=wip.id, source="wip_title_soft")) 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 HARD label trains the head 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 diff --git a/tests/test_retire_soft_wip_migration.py b/tests/test_retire_soft_wip_migration.py new file mode 100644 index 0000000..45efb58 --- /dev/null +++ b/tests/test_retire_soft_wip_migration.py @@ -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 diff --git a/tests/test_wip_title.py b/tests/test_wip_title.py index 8f69a16..b929997 100644 --- a/tests/test_wip_title.py +++ b/tests/test_wip_title.py @@ -8,7 +8,7 @@ negative cases (substrings like ``swipe`` / ``wiped``) are the load-bearing ones """ 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", [ @@ -48,29 +48,3 @@ def test_matches_positive(title): ]) def test_matches_negative(title): 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 diff --git a/tests/test_wip_title_tagging.py b/tests/test_wip_title_tagging.py index 0a40b0f..adad6f9 100644 --- a/tests/test_wip_title_tagging.py +++ b/tests/test_wip_title_tagging.py @@ -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.tag import image_tag from backend.app.services.wip_title import ( - WIP_TITLE_SOFT_SOURCE, apply_wip_image_tags, 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. 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"