feat(tagging): title-based WIP auto-tagging (#1458)
CI / lint (push) Failing after 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Failing after 3m50s

Auto-apply the `wip` system tag to posts whose TITLE explicitly declares
work-in-progress ("WIP" / "work in progress") — a deterministic, high-precision
complement to the image-based ML `wip` head. WIP images are excluded from the
Explore/gallery browse, so honouring the artist's own label keeps unfinished
pieces out of the main browse.

- services/wip_title.py: precision-first token-anchored matcher (swipe/wiped
  never trip it) + sync apply helpers (source='wip_title', ON CONFLICT DO
  NOTHING, chunked under the psycopg param ceiling).
- importer: live hook on FRESH import only (never on deep-scan/supersede), so a
  manually-removed WIP tag is never re-applied by a routine re-scan.
- maintenance.backfill_wip_title_tags: operator-triggered back-catalogue sweep
  (coarse SQL prefilter + regex confirm, keyset-paginated). Deliberately NOT a
  beat — a periodic re-run would silently undo manual removals.
- ImportSettings.wip_title_tagging_enabled (default ON, migration 0085) gating
  the live hook; GET/PATCH + POST /settings/wip-title/scan.
- Settings UI: toggle + "Scan existing posts" button.
- Tests: pure matcher unit tests + integration (apply idempotency, backfill
  precision).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 12:12:19 -04:00
parent 0cf3a02797
commit 571938781a
10 changed files with 483 additions and 3 deletions
+49
View File
@@ -0,0 +1,49 @@
"""Title-based WIP matcher (task #1458) — pure unit tests for
``matches_wip_title``, the precision-first heuristic that decides whether a post
title explicitly declares work-in-progress. No DB, no network.
Precision matters more than recall here: a false positive applies the ``wip``
system tag, which HIDES a finished post from the Explore browse — so the
negative cases (substrings like ``swipe`` / ``wiped``) are the load-bearing ones.
"""
import pytest
from backend.app.services.wip_title import matches_wip_title
@pytest.mark.parametrize("title", [
"WIP",
"wip",
"WiP",
"Nami Heroines - WIP Part 1", # the real-world example from the gate work
"sketch (WIP)",
"[WIP] new piece",
"WIP: colour test",
"cool art WIP2", # trailing digit = "WIP part 2", still WIP
"W.I.P.",
"W.I.P",
"work in progress",
"Work In Progress",
"commission work-in-progress",
"big_project_work_in_progress",
"final touches, wip",
])
def test_matches_positive(title):
assert matches_wip_title(title) is True
@pytest.mark.parametrize("title", [
None,
"",
"a quick swipe left", # 'wip' inside swipe — must NOT match
"she wiped the counter", # wiped
"wiping down the desk", # wiping
"unwiped surface",
"progressive rock cover", # 'progress' inside progressive, no 'work in'
"workin on it", # not the full phrase
"finished at last",
"Kawips diner", # 'wip' mid-word
"swipright",
])
def test_matches_negative(title):
assert matches_wip_title(title) is False
+106
View File
@@ -0,0 +1,106 @@
"""Title-based WIP auto-tagging (task #1458) — integration tests for the apply
helpers and the operator-triggered backfill sweep against a real DB. The
importer's live hook is a thin call to these same tested pieces (matcher +
apply), so the DB-facing behaviour is covered here.
"""
import pytest
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
pytestmark = pytest.mark.integration
_N = 0
@pytest.fixture(autouse=True)
def eager():
celery.conf.task_always_eager = True
yield
celery.conf.task_always_eager = False
def _img(db_sync):
global _N
_N += 1
rec = ImageRecord(
path=f"/images/w/{_N}.jpg", sha256=f"w{_N:063d}",
size_bytes=1, mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
)
db_sync.add(rec)
db_sync.flush()
return rec
def _post(db_sync, *, title, slug, ext):
a = Artist(name=slug.upper(), slug=slug)
db_sync.add(a)
db_sync.flush()
s = Source(artist_id=a.id, platform="patreon", url=f"https://patreon.test/{slug}")
db_sync.add(s)
db_sync.flush()
p = Post(source_id=s.id, artist_id=a.id, external_post_id=ext, post_title=title)
db_sync.add(p)
db_sync.flush()
return s, p
def _link(db_sync, rec, post, source):
db_sync.add(ImageProvenance(
image_record_id=rec.id, post_id=post.id, source_id=source.id,
))
db_sync.flush()
def _wip_source(db_sync, image_id, tag_id):
return db_sync.execute(
select(image_tag.c.source).where(
image_tag.c.image_record_id == image_id,
image_tag.c.tag_id == tag_id,
)
).scalar_one_or_none()
def test_resolve_wip_tag_id_present(db_sync):
# The `wip` system tag is seeded by migration 0075 and restored between tests.
assert resolve_wip_tag_id(db_sync) is not None
def test_apply_is_idempotent_and_stamps_source(db_sync):
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) == 1
# Second apply is a no-op (ON CONFLICT DO NOTHING) — never disturbs the row.
assert apply_wip_image_tags(db_sync, [rec.id], tag_id) == 0
assert _wip_source(db_sync, rec.id, tag_id) == "wip_title"
def test_backfill_tags_only_wip_titled_posts(db_sync):
from backend.app.tasks.maintenance import backfill_wip_title_tags
tag_id = resolve_wip_tag_id(db_sync)
wip_rec = _img(db_sync)
plain_rec = _img(db_sync)
swipe_rec = _img(db_sync)
s1, wip_post = _post(db_sync, title="Cool piece - WIP Part 1", slug="wipy", ext="1")
s2, plain_post = _post(db_sync, title="Finished commission", slug="doney", ext="2")
s3, swipe_post = _post(db_sync, title="a quick swipe", slug="swipey", ext="3")
_link(db_sync, wip_rec, wip_post, s1)
_link(db_sync, plain_rec, plain_post, s2)
_link(db_sync, swipe_rec, swipe_post, s3) # 'swipe' must NOT trip the matcher
db_sync.commit()
assert backfill_wip_title_tags.apply().get() == 1
assert _wip_source(db_sync, wip_rec.id, tag_id) == "wip_title"
assert _wip_source(db_sync, plain_rec.id, tag_id) is None
assert _wip_source(db_sync, swipe_rec.id, tag_id) is None
# Idempotent: a second sweep finds the tag already present and applies nothing.
assert backfill_wip_title_tags.apply().get() == 0