feat(wip): soft title tier — sketch/doodle vocab + ring-loud audit (#1474)
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>
This commit is contained in:
@@ -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")
|
||||||
@@ -49,6 +49,7 @@ _EDITABLE_FIELDS = (
|
|||||||
"translation_target_lang",
|
"translation_target_lang",
|
||||||
"translation_min_confidence",
|
"translation_min_confidence",
|
||||||
"wip_title_tagging_enabled",
|
"wip_title_tagging_enabled",
|
||||||
|
"wip_soft_title_tagging_enabled",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Per-host external-download toggles — all plain booleans, validated uniformly.
|
# 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_target_lang": row.translation_target_lang,
|
||||||
"translation_min_confidence": row.translation_min_confidence,
|
"translation_min_confidence": row.translation_min_confidence,
|
||||||
"wip_title_tagging_enabled": row.wip_title_tagging_enabled,
|
"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(
|
return jsonify(
|
||||||
{"error": "wip_title_tagging_enabled must be a boolean"}
|
{"error": "wip_title_tagging_enabled must be a boolean"}
|
||||||
), 400
|
), 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:
|
async with get_session() as session:
|
||||||
row = await ImportSettings.load(session)
|
row = await ImportSettings.load(session)
|
||||||
|
|||||||
@@ -179,6 +179,11 @@ def make_celery() -> Celery:
|
|||||||
"schedule": 86400.0, # auto-tag wip/editor process art (#1464);
|
"schedule": 86400.0, # auto-tag wip/editor process art (#1464);
|
||||||
# no-op unless process_auto_apply_enabled (opt-in)
|
# 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": {
|
"prune-presentation-reviews-daily": {
|
||||||
"task": "backend.app.tasks.ml.prune_presentation_reviews",
|
"task": "backend.app.tasks.ml.prune_presentation_reviews",
|
||||||
"schedule": 86400.0, # retention: drop resolved review flags >30d
|
"schedule": 86400.0, # retention: drop resolved review flags >30d
|
||||||
|
|||||||
@@ -126,6 +126,13 @@ class ImportSettings(Base):
|
|||||||
wip_title_tagging_enabled: Mapped[bool] = mapped_column(
|
wip_title_tagging_enabled: Mapped[bool] = mapped_column(
|
||||||
Boolean, nullable=False, default=True, server_default="true",
|
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
|
@classmethod
|
||||||
async def load(cls, session) -> ImportSettings:
|
async def load(cls, session) -> ImportSettings:
|
||||||
|
|||||||
@@ -47,7 +47,14 @@ from .attachment_store import AttachmentStore
|
|||||||
from .audits import single_color
|
from .audits import single_color
|
||||||
from .link_extract import extract_external_links
|
from .link_extract import extract_external_links
|
||||||
from .thumbnailer import Thumbnailer
|
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__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -999,7 +1006,9 @@ class Importer:
|
|||||||
removal sticks. The existing catalogue is covered separately by the
|
removal sticks. The existing catalogue is covered separately by the
|
||||||
operator-triggered backfill sweep. Gated by the settings toggle, and
|
operator-triggered backfill sweep. Gated by the settings toggle, and
|
||||||
best-effort: any failure is logged, never allowed to fail the import."""
|
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
|
return
|
||||||
if record.primary_post_id is None:
|
if record.primary_post_id is None:
|
||||||
return
|
return
|
||||||
@@ -1007,13 +1016,22 @@ class Importer:
|
|||||||
title = self.session.execute(
|
title = self.session.execute(
|
||||||
select(Post.post_title).where(Post.id == record.primary_post_id)
|
select(Post.post_title).where(Post.id == record.primary_post_id)
|
||||||
).scalar_one_or_none()
|
).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
|
return
|
||||||
if self._wip_tag_id is _UNSET:
|
if self._wip_tag_id is _UNSET:
|
||||||
self._wip_tag_id = resolve_wip_tag_id(self.session)
|
self._wip_tag_id = resolve_wip_tag_id(self.session)
|
||||||
if self._wip_tag_id is None:
|
if self._wip_tag_id is None:
|
||||||
return
|
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
|
except Exception as exc: # noqa: BLE001 — a tag must never fail an import
|
||||||
log.warning(
|
log.warning(
|
||||||
"wip-title auto-tag failed for image %s: %s", record.id, exc
|
"wip-title auto-tag failed for image %s: %s", record.id, exc
|
||||||
|
|||||||
@@ -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 <X>", 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:
|
def retract_auto_applied_heads(session: Session) -> int:
|
||||||
"""Soft auto-apply (milestone 139): re-score every standing source='head_auto'
|
"""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
|
tag against its CURRENT head and REMOVE the ones now BELOW the head's
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ from ...models.tag import image_tag
|
|||||||
# `process_auto` (#1464): wip/editor screenshot applied by the process sweep are
|
# `process_auto` (#1464): wip/editor screenshot applied by the process sweep are
|
||||||
# ALSO provisional — the head must learn only from title (`wip_title`) + manual
|
# 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).
|
# 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]:
|
def _hygiene_excluded_ids(session: Session) -> set[int]:
|
||||||
|
|||||||
@@ -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
|
# 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.
|
# 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"
|
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"
|
# A standalone "WIP" / "W.I.P" token, or the phrase "work in progress"
|
||||||
# (space/underscore/hyphen separated). The letter-boundary lookarounds are what
|
# (space/underscore/hyphen separated). The letter-boundary lookarounds are what
|
||||||
@@ -39,11 +45,20 @@ _WIP_RE = re.compile(
|
|||||||
re.IGNORECASE,
|
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"(?<![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.
|
# COULD match before the precise regex confirms. Case-insensitive ILIKE patterns.
|
||||||
# MUST stay a SUPERSET of _WIP_RE (every regex match contains "wip" or
|
# Each MUST stay a SUPERSET of its regex or the sweep would silently miss posts.
|
||||||
# "work…progress") or the sweep would silently miss posts.
|
|
||||||
WIP_TITLE_SQL_PREFILTER = ("%wip%", "%work%progress%")
|
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
|
# 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).
|
# ceiling (3 params/row → ~21k rows max; 5k stays comfortably under).
|
||||||
@@ -51,12 +66,19 @@ _INSERT_CHUNK = 5000
|
|||||||
|
|
||||||
|
|
||||||
def matches_wip_title(title: str | None) -> bool:
|
def matches_wip_title(title: str | None) -> 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:
|
if not title:
|
||||||
return False
|
return False
|
||||||
return _WIP_RE.search(title) is not None
|
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:
|
def resolve_wip_tag_id(session: Session) -> int | None:
|
||||||
"""The seeded ``wip`` system tag's id (migration 0075), or None if absent."""
|
"""The seeded ``wip`` system tag's id (migration 0075), or None if absent."""
|
||||||
return session.execute(
|
return session.execute(
|
||||||
@@ -64,8 +86,10 @@ def resolve_wip_tag_id(session: Session) -> int | None:
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
def apply_wip_image_tags(session: Session, image_ids, tag_id: int) -> int:
|
def apply_wip_image_tags(
|
||||||
"""Attach ``tag_id`` (source='wip_title') to each image id, idempotently —
|
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
|
never disturbs an existing tag or its source. Returns the number of image_tag
|
||||||
rows newly inserted. Does NOT commit.
|
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(
|
session.execute(
|
||||||
pg_insert(image_tag)
|
pg_insert(image_tag)
|
||||||
.values([
|
.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
|
for iid in to_insert
|
||||||
])
|
])
|
||||||
.on_conflict_do_nothing(index_elements=["image_record_id", "tag_id"])
|
.on_conflict_do_nothing(index_elements=["image_record_id", "tag_id"])
|
||||||
|
|||||||
@@ -1047,6 +1047,41 @@ def cleanup_old_download_events() -> int:
|
|||||||
return result.rowcount or 0
|
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(
|
@celery.task(
|
||||||
name="backend.app.tasks.maintenance.backfill_wip_title_tags",
|
name="backend.app.tasks.maintenance.backfill_wip_title_tags",
|
||||||
# Coarse-prefiltered scan over posts; the candidate set is small on a typical
|
# 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,
|
soft_time_limit=1800, time_limit=2100,
|
||||||
)
|
)
|
||||||
def backfill_wip_title_tags() -> int:
|
def backfill_wip_title_tags() -> int:
|
||||||
"""Scan EXISTING posts for explicit WIP titles and apply the `wip` system tag
|
"""Scan EXISTING posts for WIP titles and apply the `wip` system tag to their
|
||||||
to their images — the operator-triggered back-catalogue catch-up for
|
images — the operator-triggered back-catalogue catch-up (task #1458 hard tier +
|
||||||
title-based WIP tagging (task #1458). New imports are tagged live by the
|
#1474 soft tier). New imports are tagged live by the importer; this covers the
|
||||||
importer; this covers everything already in the library.
|
existing library.
|
||||||
|
|
||||||
Keyset-paginated over posts (restart-safe). A coarse SQL prefilter narrows to
|
HARD tier ("WIP"/"work in progress") always runs (the operator triggered the
|
||||||
titles that COULD match; the precise regex (matches_wip_title) confirms.
|
scan); the SOFT tier (sketch/doodle, provisional source) runs only when
|
||||||
Idempotent-additive (ON CONFLICT DO NOTHING) — never disturbs an existing tag.
|
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
|
Deliberately NOT scheduled as a beat: a periodic re-run would re-apply to matching
|
||||||
matching posts and silently undo a manual WIP removal, so it stays an explicit
|
posts and silently undo a manual WIP removal, so it stays an explicit operator
|
||||||
operator action (Settings → "Scan existing posts for WIP titles"). Returns the
|
action (Settings → "Scan existing posts for WIP titles"). Returns rows applied.
|
||||||
number of image-tag rows newly applied.
|
|
||||||
"""
|
"""
|
||||||
from ..models import Post
|
from ..models import ImportSettings
|
||||||
from ..models.image_provenance import ImageProvenance
|
|
||||||
from ..services.wip_title import (
|
from ..services.wip_title import (
|
||||||
|
SOFT_WIP_TITLE_SQL_PREFILTER,
|
||||||
|
WIP_TITLE_SOFT_SOURCE,
|
||||||
|
WIP_TITLE_SOURCE,
|
||||||
WIP_TITLE_SQL_PREFILTER,
|
WIP_TITLE_SQL_PREFILTER,
|
||||||
apply_wip_image_tags,
|
matches_soft_wip_title,
|
||||||
matches_wip_title,
|
matches_wip_title,
|
||||||
resolve_wip_tag_id,
|
resolve_wip_tag_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
SessionLocal = _sync_session_factory()
|
SessionLocal = _sync_session_factory()
|
||||||
applied = 0
|
|
||||||
last_id = 0
|
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
tag_id = resolve_wip_tag_id(session)
|
tag_id = resolve_wip_tag_id(session)
|
||||||
if tag_id is None:
|
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"
|
"backfill_wip_title_tags: no `wip` system tag present; nothing to do"
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
like_a, like_b = WIP_TITLE_SQL_PREFILTER
|
settings = ImportSettings.load_sync(session)
|
||||||
while True:
|
applied = _backfill_wip_tier(
|
||||||
rows = session.execute(
|
session, tag_id, WIP_TITLE_SQL_PREFILTER, matches_wip_title,
|
||||||
select(Post.id, Post.post_title)
|
WIP_TITLE_SOURCE,
|
||||||
.where(Post.id > last_id)
|
)
|
||||||
.where(Post.post_title.is_not(None))
|
if settings.wip_soft_title_tagging_enabled:
|
||||||
.where(or_(
|
applied += _backfill_wip_tier(
|
||||||
Post.post_title.ilike(like_a),
|
session, tag_id, SOFT_WIP_TITLE_SQL_PREFILTER, matches_soft_wip_title,
|
||||||
Post.post_title.ilike(like_b),
|
WIP_TITLE_SOFT_SOURCE,
|
||||||
))
|
)
|
||||||
.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()
|
|
||||||
if applied:
|
if applied:
|
||||||
log.info("backfill_wip_title_tags: applied wip to %d image(s)", applied)
|
log.info("backfill_wip_title_tags: applied wip to %d image(s)", applied)
|
||||||
return applied
|
return applied
|
||||||
|
|||||||
@@ -629,6 +629,24 @@ def scheduled_process_auto_apply() -> str:
|
|||||||
return f"applied={result['n_applied']} flagged={result['n_flagged']}"
|
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")
|
@celery.task(name="backend.app.tasks.ml.prune_presentation_reviews")
|
||||||
def prune_presentation_reviews() -> str:
|
def prune_presentation_reviews() -> str:
|
||||||
"""Retention (rule 89): drop RESOLVED presentation-review flags older than 30
|
"""Retention (rule 89): drop RESOLVED presentation-review flags older than 30
|
||||||
|
|||||||
@@ -99,6 +99,17 @@
|
|||||||
the Explore browse. Applies to new imports; run the scan below to catch
|
the Explore browse. Applies to new imports; run the scan below to catch
|
||||||
posts already in your library.
|
posts already in your library.
|
||||||
</div>
|
</div>
|
||||||
|
<v-switch
|
||||||
|
v-model="local.wip_soft_title_tagging_enabled"
|
||||||
|
label="Also tag “sketch” / “doodle” titles (lower precision)"
|
||||||
|
density="compact" hide-details color="primary" @change="save"
|
||||||
|
/>
|
||||||
|
<div class="fc-help mb-3">
|
||||||
|
Extends the above to softer cues (<code>sketch</code>, <code>doodle</code>,
|
||||||
|
<code>scribble</code>). These stay <strong>visible</strong> and never train
|
||||||
|
the tagging model — a daily audit flags any that actually look like finished
|
||||||
|
art for review. Off by default.
|
||||||
|
</div>
|
||||||
<v-btn
|
<v-btn
|
||||||
variant="tonal" color="primary" size="small"
|
variant="tonal" color="primary" size="small"
|
||||||
:loading="store.wipScanBusy" prepend-icon="mdi-magnify"
|
:loading="store.wipScanBusy" prepend-icon="mdi-magnify"
|
||||||
@@ -139,6 +150,7 @@ const local = reactive({
|
|||||||
skip_single_color: false, single_color_threshold: 0.95,
|
skip_single_color: false, single_color_threshold: 0.95,
|
||||||
phash_threshold: 10,
|
phash_threshold: 10,
|
||||||
wip_title_tagging_enabled: true,
|
wip_title_tagging_enabled: true,
|
||||||
|
wip_soft_title_tagging_enabled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||||
|
|||||||
@@ -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):
|
def test_process_auto_source_never_trains_head(db_sync):
|
||||||
# The runaway break: a wip tag the process sweep applied (source='process_auto')
|
# The runaway break: provisional wip tags (process sweep 'process_auto', soft
|
||||||
# is NOT a training positive; a title-heuristic / manual one IS. So the head
|
# title 'wip_title_soft') are NOT training positives; a HARD title-heuristic /
|
||||||
# learns only from trusted labels, never its own output.
|
# 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")
|
wip = _system_tag(db_sync, "wip")
|
||||||
auto_img = _img(db_sync, "f" * 64, _emb(0))
|
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))
|
title_img = _img(db_sync, "0" * 64, _emb(1))
|
||||||
db_sync.execute(image_tag.insert().values(
|
db_sync.execute(image_tag.insert().values(
|
||||||
image_record_id=auto_img.id, tag_id=wip.id, source="process_auto"))
|
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(
|
db_sync.execute(image_tag.insert().values(
|
||||||
image_record_id=title_img.id, tag_id=wip.id, source="wip_title"))
|
image_record_id=title_img.id, tag_id=wip.id, source="wip_title"))
|
||||||
db_sync.commit()
|
db_sync.commit()
|
||||||
positives = set(_ids_with_tag(db_sync, wip.id))
|
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 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
|
||||||
|
|||||||
+28
-1
@@ -8,7 +8,7 @@ negative cases (substrings like ``swipe`` / ``wiped``) are the load-bearing ones
|
|||||||
"""
|
"""
|
||||||
import pytest
|
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", [
|
@pytest.mark.parametrize("title", [
|
||||||
@@ -44,6 +44,33 @@ def test_matches_positive(title):
|
|||||||
"finished at last",
|
"finished at last",
|
||||||
"Kawips diner", # 'wip' mid-word
|
"Kawips diner", # 'wip' mid-word
|
||||||
"swipright",
|
"swipright",
|
||||||
|
"quick sketch of Nami", # soft cue — NOT a HARD WIP match
|
||||||
])
|
])
|
||||||
def test_matches_negative(title):
|
def test_matches_negative(title):
|
||||||
assert matches_wip_title(title) is False
|
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
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ from sqlalchemy import select
|
|||||||
from backend.app.celery_app import celery
|
from backend.app.celery_app import celery
|
||||||
from backend.app.models import Artist, ImageProvenance, ImageRecord, Post, Source
|
from backend.app.models import Artist, ImageProvenance, ImageRecord, Post, Source
|
||||||
from backend.app.models.tag import image_tag
|
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
|
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.
|
# Idempotent: a second sweep finds the tag already present and applies nothing.
|
||||||
assert backfill_wip_title_tags.apply().get() == 0
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user