af0d39ed52
Extends WIP title-tagging to lower-precision cues (sketch/doodle/scribble) safely. - wip_title.py: soft matcher (word-anchored; sketchbook/kadoodle don't trip it); WIP_TITLE_SOFT_SOURCE + soft SQL prefilter; apply_wip_image_tags takes a source arg. - training_data._AUTO_SOURCES += 'wip_title_soft' → the soft tier is PROVISIONAL and never trains the wip head (a finished "sketch" can't pollute it). Only the hard tier (wip_title) + manual train. - ImportSettings.wip_soft_title_tagging_enabled (OFF by default, opt-in). Migration 0087. - importer: hard tier wins, soft is the fallback (source wip_title_soft). - backfill: refactored into a shared _backfill_wip_tier; hard always, soft when enabled. - heads.soft_wip_conflict_audit + daily beat: score soft-tagged images against content heads, flag ring-loud ones (PresentationReview mode=process) for the review strip — the operator's "measure if they got falsely tagged" safety. - api settings toggle; ImportFiltersForm soft toggle. - tests: soft matcher pos/neg; soft source not a training positive; audit flags ring-loud + spares quiet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
5.6 KiB
Python
126 lines
5.6 KiB
Python
"""Title-based WIP auto-tagging (task #1458).
|
|
|
|
Deterministic heuristic: when a post's TITLE explicitly declares work-in-progress
|
|
(the artist's own "WIP" / "work in progress" label), the ``wip`` system tag is
|
|
applied to that post's images — a cheap, high-precision complement to the
|
|
image-based ML ``wip`` head. WIP images are excluded from the Explore/gallery
|
|
browse (see gallery_service ``excluded_system_tags``), so honouring the artist's
|
|
own label keeps unfinished pieces out of the main browse right at import.
|
|
|
|
Precision over recall — a false WIP tag HIDES a finished post — so matching is
|
|
token-anchored: ``swipe`` / ``wiped`` / ``wiping`` never trip it (a letter on the
|
|
boundary blocks the match).
|
|
|
|
Sync-only: both consumers (the importer and the backfill Celery task) run on a
|
|
sync Session. Application is idempotent-additive (ON CONFLICT DO NOTHING) and
|
|
stamps a distinct ``image_tag.source`` so a later pass can tell where a wip tag
|
|
came from — the "manual" / "head_auto" / "ccip_auto" / "ml_accepted" provenance
|
|
family gains one member.
|
|
"""
|
|
import re
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
from sqlalchemy.orm import Session
|
|
|
|
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
|
|
# make this precision-first: `s|wip|e`, `|wip|ed`, `|wip|ing` all have a letter
|
|
# abutting the token, so they're rejected. A trailing digit is allowed so
|
|
# "WIP2" (= WIP part 2) still matches.
|
|
_WIP_RE = re.compile(
|
|
r"(?<![A-Za-z])(?:w\.?i\.?p\.?|work[\s_-]+in[\s_-]+progress)(?![A-Za-z])",
|
|
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"(?<![A-Za-z])(?:sketch|sketches|doodle|doodles|scribble|scribbles)(?![A-Za-z])",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Coarse SQL prefilters for the backfill sweep — narrow the post scan to rows that
|
|
# COULD match before the precise regex confirms. Case-insensitive ILIKE patterns.
|
|
# Each MUST stay a SUPERSET of its regex or the sweep would silently miss posts.
|
|
WIP_TITLE_SQL_PREFILTER = ("%wip%", "%work%progress%")
|
|
SOFT_WIP_TITLE_SQL_PREFILTER = ("%sketch%", "%doodle%", "%scribble%")
|
|
|
|
# Chunk bulk inserts so a large sweep can't blow past psycopg's 65535-parameter
|
|
# ceiling (3 params/row → ~21k rows max; 5k stays comfortably under).
|
|
_INSERT_CHUNK = 5000
|
|
|
|
|
|
def matches_wip_title(title: str | None) -> bool:
|
|
"""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(
|
|
select(Tag.id).where(Tag.name == WIP_SYSTEM_TAG, Tag.is_system.is_(True))
|
|
).scalar_one_or_none()
|
|
|
|
|
|
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.
|
|
|
|
The insert count is computed from a pre-SELECT of already-tagged ids rather
|
|
than the statement's ``rowcount``: psycopg reports -1 for a multi-row
|
|
ON CONFLICT DO NOTHING insert (it runs via an executemany path), so rowcount
|
|
is unusable here. The SELECT is accurate within this single transaction (no
|
|
concurrent writer touches these (image, wip) rows); ON CONFLICT DO NOTHING
|
|
stays as a race-safety belt so a rare concurrent insert can't error."""
|
|
ids = list({int(i) for i in image_ids})
|
|
if not ids:
|
|
return 0
|
|
inserted = 0
|
|
for start in range(0, len(ids), _INSERT_CHUNK):
|
|
chunk = ids[start:start + _INSERT_CHUNK]
|
|
already = set(session.execute(
|
|
select(image_tag.c.image_record_id)
|
|
.where(image_tag.c.tag_id == tag_id)
|
|
.where(image_tag.c.image_record_id.in_(chunk))
|
|
).scalars())
|
|
to_insert = [iid for iid in chunk if iid not in already]
|
|
if not to_insert:
|
|
continue
|
|
session.execute(
|
|
pg_insert(image_tag)
|
|
.values([
|
|
{"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"])
|
|
)
|
|
inserted += len(to_insert)
|
|
return inserted
|