diff --git a/alembic/versions/0087_wip_soft_title_tagging.py b/alembic/versions/0087_wip_soft_title_tagging.py new file mode 100644 index 0000000..58478e1 --- /dev/null +++ b/alembic/versions/0087_wip_soft_title_tagging.py @@ -0,0 +1,33 @@ +"""soft WIP title tier toggle (#1474) — ImportSettings.wip_soft_title_tagging_enabled + +The soft tier also tags sketch/doodle/scribble titles, but with a provisional source +that never trains the head. OFF by default (a lower-precision tier is opt-in). +server_default so the existing singleton row (id=1) fills cleanly. + +Revision ID: 0087 +Revises: 0086 +Create Date: 2026-07-13 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0087" +down_revision: Union[str, None] = "0086" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "import_settings", + sa.Column( + "wip_soft_title_tagging_enabled", sa.Boolean(), nullable=False, + server_default=sa.text("false"), + ), + ) + + +def downgrade() -> None: + op.drop_column("import_settings", "wip_soft_title_tagging_enabled") diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index df372ea..d2d916a 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -49,6 +49,7 @@ _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. @@ -91,6 +92,7 @@ async def get_import_settings(): "translation_target_lang": row.translation_target_lang, "translation_min_confidence": row.translation_min_confidence, "wip_title_tagging_enabled": row.wip_title_tagging_enabled, + "wip_soft_title_tagging_enabled": row.wip_soft_title_tagging_enabled, }) @@ -179,6 +181,12 @@ 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 20dd691..d8983ed 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -179,6 +179,11 @@ 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 c17ffd5..f4b8937 100644 --- a/backend/app/models/import_settings.py +++ b/backend/app/models/import_settings.py @@ -126,6 +126,13 @@ 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 b773d89..db8cf0c 100644 --- a/backend/app/services/importer.py +++ b/backend/app/services/importer.py @@ -47,7 +47,14 @@ from .attachment_store import AttachmentStore from .audits import single_color from .link_extract import extract_external_links from .thumbnailer import Thumbnailer -from .wip_title import apply_wip_image_tags, matches_wip_title, resolve_wip_tag_id +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, +) log = logging.getLogger(__name__) @@ -999,7 +1006,9 @@ 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.""" - if not self.settings.wip_title_tagging_enabled: + hard_on = self.settings.wip_title_tagging_enabled + soft_on = self.settings.wip_soft_title_tagging_enabled + if not (hard_on or soft_on): return if record.primary_post_id is None: return @@ -1007,13 +1016,22 @@ class Importer: title = self.session.execute( select(Post.post_title).where(Post.id == record.primary_post_id) ).scalar_one_or_none() - if not matches_wip_title(title): + # 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: 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) + apply_wip_image_tags( + self.session, [record.id], self._wip_tag_id, source=source + ) except Exception as exc: # noqa: BLE001 — a tag must never fail an import log.warning( "wip-title auto-tag failed for image %s: %s", record.id, exc diff --git a/backend/app/services/ml/heads.py b/backend/app/services/ml/heads.py index 12472cf..ae5e7fc 100644 --- a/backend/app/services/ml/heads.py +++ b/backend/app/services/ml/heads.py @@ -947,6 +947,74 @@ 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 sqlalchemy.dialects.postgresql import insert as pg_insert + + 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) + cprobs = 1.0 / (1.0 + np.exp(-(Xn @ Wc.T + bc))) + max_c = cprobs.max(axis=1) + arg_c = cprobs.argmax(axis=1) + for k in range(len(cids)): + if float(max_c[k]) >= conflict_thr: + n_flagged += 1 + if not dry_run: + session.execute( + pg_insert(PresentationReview) + .values( + 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", + ) + .on_conflict_do_nothing() + ) + 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 ff6ec78..b88ebbd 100644 --- a/backend/app/services/ml/training_data.py +++ b/backend/app/services/ml/training_data.py @@ -32,7 +32,12 @@ 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). -_AUTO_SOURCES = ("head_auto", "ccip_auto", "ml_auto", "presentation_auto", "process_auto") +# `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", +) def _hygiene_excluded_ids(session: Session) -> set[int]: diff --git a/backend/app/services/wip_title.py b/backend/app/services/wip_title.py index 7ad13d4..49a18a4 100644 --- a/backend/app/services/wip_title.py +++ b/backend/app/services/wip_title.py @@ -27,7 +27,13 @@ from ..models.tag import WIP_SYSTEM_TAG, Tag, image_tag # 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. 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 @@ -39,11 +45,20 @@ _WIP_RE = re.compile( re.IGNORECASE, ) -# Coarse SQL prefilter for the backfill sweep — narrows the post scan to rows that +# 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.""" + """True when a post title explicitly marks it work-in-progress (HARD tier).""" 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( @@ -64,8 +86,10 @@ def resolve_wip_tag_id(session: Session) -> int | None: ).scalar_one_or_none() -def apply_wip_image_tags(session: Session, image_ids, tag_id: int) -> int: - """Attach ``tag_id`` (source='wip_title') to each image id, idempotently — +def apply_wip_image_tags( + session: Session, image_ids, tag_id: int, *, source: str = WIP_TITLE_SOURCE +) -> int: + """Attach ``tag_id`` (stamped with ``source``) to each image id, idempotently — never disturbs an existing tag or its source. Returns the number of image_tag rows newly inserted. Does NOT commit. @@ -92,7 +116,7 @@ def apply_wip_image_tags(session: Session, image_ids, tag_id: int) -> int: session.execute( pg_insert(image_tag) .values([ - {"image_record_id": iid, "tag_id": tag_id, "source": WIP_TITLE_SOURCE} + {"image_record_id": iid, "tag_id": tag_id, "source": source} for iid in to_insert ]) .on_conflict_do_nothing(index_elements=["image_record_id", "tag_id"]) diff --git a/backend/app/tasks/maintenance.py b/backend/app/tasks/maintenance.py index 577213b..2ad0b3f 100644 --- a/backend/app/tasks/maintenance.py +++ b/backend/app/tasks/maintenance.py @@ -1047,6 +1047,41 @@ def cleanup_old_download_events() -> int: return result.rowcount or 0 +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 + `matcher` confirms. Idempotent-additive (ON CONFLICT DO NOTHING). Returns the row + count newly applied.""" + from ..models import Post + from ..models.image_provenance import ImageProvenance + from ..services.wip_title import apply_wip_image_tags + + applied = 0 + last_id = 0 + while True: + rows = session.execute( + select(Post.id, Post.post_title) + .where(Post.id > last_id) + .where(Post.post_title.is_not(None)) + .where(or_(*[Post.post_title.ilike(p) for p in prefilter])) + .order_by(Post.id.asc()) + .limit(WIP_BACKFILL_PAGE) + ).all() + if not rows: + break + last_id = rows[-1][0] + match_ids = [pid for pid, title in rows if matcher(title)] + if match_ids: + image_ids = session.execute( + select(ImageProvenance.image_record_id) + .where(ImageProvenance.post_id.in_(match_ids)) + ).scalars().all() + applied += apply_wip_image_tags(session, image_ids, tag_id, source=source) + session.commit() + return applied + + @celery.task( name="backend.app.tasks.maintenance.backfill_wip_title_tags", # Coarse-prefiltered scan over posts; the candidate set is small on a typical @@ -1054,32 +1089,32 @@ def cleanup_old_download_events() -> int: soft_time_limit=1800, time_limit=2100, ) def backfill_wip_title_tags() -> int: - """Scan EXISTING posts for explicit WIP titles and apply the `wip` system tag - to their images — the operator-triggered back-catalogue catch-up for - title-based WIP tagging (task #1458). New imports are tagged live by the - importer; this covers everything already in the library. + """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. - Keyset-paginated over posts (restart-safe). A coarse SQL prefilter narrows to - titles that COULD match; the precise regex (matches_wip_title) confirms. - Idempotent-additive (ON CONFLICT DO NOTHING) — never disturbs an existing tag. + 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 posts and silently undo a manual WIP removal, so it stays an explicit - operator action (Settings → "Scan existing posts for WIP titles"). Returns the - number of image-tag rows newly applied. + 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 Post - from ..models.image_provenance import ImageProvenance + 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, - apply_wip_image_tags, + matches_soft_wip_title, matches_wip_title, resolve_wip_tag_id, ) SessionLocal = _sync_session_factory() - applied = 0 - last_id = 0 with SessionLocal() as session: tag_id = resolve_wip_tag_id(session) if tag_id is None: @@ -1087,30 +1122,16 @@ def backfill_wip_title_tags() -> int: "backfill_wip_title_tags: no `wip` system tag present; nothing to do" ) return 0 - like_a, like_b = WIP_TITLE_SQL_PREFILTER - while True: - rows = session.execute( - select(Post.id, Post.post_title) - .where(Post.id > last_id) - .where(Post.post_title.is_not(None)) - .where(or_( - Post.post_title.ilike(like_a), - Post.post_title.ilike(like_b), - )) - .order_by(Post.id.asc()) - .limit(WIP_BACKFILL_PAGE) - ).all() - if not rows: - break - last_id = rows[-1][0] - match_ids = [pid for pid, title in rows if matches_wip_title(title)] - if match_ids: - image_ids = session.execute( - select(ImageProvenance.image_record_id) - .where(ImageProvenance.post_id.in_(match_ids)) - ).scalars().all() - applied += apply_wip_image_tags(session, image_ids, tag_id) - session.commit() + 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 e6bec0c..f25d0fe 100644 --- a/backend/app/tasks/ml.py +++ b/backend/app/tasks/ml.py @@ -629,6 +629,24 @@ 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/settings/ImportFiltersForm.vue b/frontend/src/components/settings/ImportFiltersForm.vue index d4d99c9..52e3196 100644 --- a/frontend/src/components/settings/ImportFiltersForm.vue +++ b/frontend/src/components/settings/ImportFiltersForm.vue @@ -99,6 +99,17 @@ 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 3824399..1ed5609 100644 --- a/tests/test_process_auto_apply.py +++ b/tests/test_process_auto_apply.py @@ -136,17 +136,53 @@ def test_process_sweep_flags_conflict_with_process_mode(db_sync): 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. + # 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). 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 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 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_wip_title.py b/tests/test_wip_title.py index 8677451..8f69a16 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_wip_title +from backend.app.services.wip_title import matches_soft_wip_title, matches_wip_title @pytest.mark.parametrize("title", [ @@ -44,6 +44,33 @@ def test_matches_positive(title): "finished at last", "Kawips diner", # 'wip' mid-word "swipright", + "quick sketch of Nami", # soft cue — NOT a HARD WIP match ]) 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 0e40ec0..0a40b0f 100644 --- a/tests/test_wip_title_tagging.py +++ b/tests/test_wip_title_tagging.py @@ -9,7 +9,11 @@ from sqlalchemy import select 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 apply_wip_image_tags, resolve_wip_tag_id +from backend.app.services.wip_title import ( + WIP_TITLE_SOFT_SOURCE, + apply_wip_image_tags, + resolve_wip_tag_id, +) pytestmark = pytest.mark.integration @@ -104,3 +108,14 @@ 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"