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:
@@ -43,6 +43,7 @@ from ..utils.phash import compute_phash, find_similar
|
||||
from ..utils.sidecar import find_sidecar, parse_sidecar
|
||||
from ..utils.slug import slugify
|
||||
from .archive_extractor import extract_archive, is_archive
|
||||
from .wip_title import apply_wip_image_tags, matches_wip_title, resolve_wip_tag_id
|
||||
from .attachment_store import AttachmentStore
|
||||
from .audits import single_color
|
||||
from .link_extract import extract_external_links
|
||||
@@ -50,6 +51,10 @@ from .thumbnailer import Thumbnailer
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel for the lazily-resolved wip tag id (distinguishes "not resolved yet"
|
||||
# from a genuine None = tag absent, so absence is cached and not re-queried).
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
class SkipReason(StrEnum):
|
||||
too_small = "too_small"
|
||||
@@ -183,6 +188,10 @@ class Importer:
|
||||
# invalidated mid-Importer (Importer instances are per-task /
|
||||
# per-archive-import so cross-instance staleness is harmless).
|
||||
self._phash_candidates: list[tuple] | None = None
|
||||
# Lazily-resolved `wip` system tag id for title-based WIP auto-tagging
|
||||
# (task #1458). Sentinel _UNSET so a genuine None (tag absent) is cached
|
||||
# and not re-queried per media. Importer is per-task, so this can't stale.
|
||||
self._wip_tag_id: int | None = _UNSET
|
||||
|
||||
def _phash_candidates_cache(self) -> list[tuple]:
|
||||
"""Cached `(phash, width, height, id)` rows from image_record.
|
||||
@@ -933,6 +942,10 @@ class Importer:
|
||||
# Thumbnail is queued separately by the calling task; the importer
|
||||
# does not generate thumbnails inline so the import queue stays moving.
|
||||
|
||||
# Title-based WIP auto-tag (task #1458): fresh import only, after the
|
||||
# sidecar has linked the post so record.primary_post_id / its title exist.
|
||||
self._maybe_apply_wip_title(record)
|
||||
|
||||
self.session.commit()
|
||||
return ImportResult(status="imported", image_id=record.id)
|
||||
|
||||
@@ -976,6 +989,36 @@ class Importer:
|
||||
self.session.commit()
|
||||
return ImportResult(status="refreshed", image_id=existing.id)
|
||||
|
||||
def _maybe_apply_wip_title(self, record: ImageRecord) -> None:
|
||||
"""Auto-apply the `wip` system tag to a FRESHLY-imported image when its
|
||||
primary post's TITLE explicitly declares work-in-progress (task #1458 —
|
||||
the artist's own "WIP" / "work in progress" label).
|
||||
|
||||
Called ONLY from the two new-record paths (never deep-scan / supersede),
|
||||
so a manually-removed WIP tag is never re-applied by a routine re-scan —
|
||||
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:
|
||||
return
|
||||
if record.primary_post_id is None:
|
||||
return
|
||||
try:
|
||||
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):
|
||||
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)
|
||||
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
|
||||
)
|
||||
|
||||
def _apply_post_fields(self, post: Post, sd) -> None:
|
||||
"""Write a parsed sidecar's post-level fields onto a Post — the SINGLE
|
||||
predicate shared by BOTH ingest paths: the per-media path (_apply_sidecar)
|
||||
@@ -1253,6 +1296,10 @@ class Importer:
|
||||
# per-post Source row.
|
||||
self._apply_sidecar(record, path, artist, explicit_source=source)
|
||||
|
||||
# Title-based WIP auto-tag (task #1458): fresh import only, see the
|
||||
# matching call in _import_media.
|
||||
self._maybe_apply_wip_title(record)
|
||||
|
||||
self.session.commit()
|
||||
return ImportResult(status="imported", image_id=record.id)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user