feat(tagging): title-based WIP auto-tagging (#1458)
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:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user