Merge pull request 'Ship: system-tag refactor + soft WIP tier + Explore reach (#1464, #1474, #1476)' (#223) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m45s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m45s
This commit was merged in pull request #223.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"""process auto-apply settings + review mode (#1464) — system-tag refactor
|
||||
|
||||
The system-tag behavior refactor gives `wip` / `editor screenshot` (the PROCESS
|
||||
group) their own provisional auto-apply, parallel to the presentation (chrome)
|
||||
sweep. MLSettings gains three knobs: enabled (OFF by default — a new whole-library
|
||||
auto-tagger is opt-in), the flat apply threshold, and the ring-loud conflict
|
||||
threshold. presentation_review gains a `mode` column so one review surface serves
|
||||
both chrome and process flags (existing rows backfill 'chrome'). server_defaults
|
||||
so the existing rows fill cleanly.
|
||||
|
||||
Revision ID: 0086
|
||||
Revises: 0085
|
||||
Create Date: 2026-07-13
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0086"
|
||||
down_revision: Union[str, None] = "0085"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"process_auto_apply_enabled", sa.Boolean(), nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"process_auto_apply_threshold", sa.Float(), nullable=False,
|
||||
server_default="0.90",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"process_conflict_threshold", sa.Float(), nullable=False,
|
||||
server_default="0.50",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"presentation_review",
|
||||
sa.Column(
|
||||
"mode", sa.String(16), nullable=False,
|
||||
server_default="chrome",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("presentation_review", "mode")
|
||||
op.drop_column("ml_settings", "process_conflict_threshold")
|
||||
op.drop_column("ml_settings", "process_auto_apply_threshold")
|
||||
op.drop_column("ml_settings", "process_auto_apply_enabled")
|
||||
@@ -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")
|
||||
@@ -148,6 +148,17 @@ async def similar():
|
||||
# Explore passes exclude_wip=1 to also drop work-in-progress from the
|
||||
# rabbit-hole; the gallery's own "similar" button omits it (keeps wip, #1274).
|
||||
exclude_wip = request.args.get("exclude_wip") in ("1", "true", "True")
|
||||
# Explore reach (#1476): 0 = nearest (gallery default), →1 reaches into farther
|
||||
# distance bands so the walk can escape a dense cluster. exclude_ids = the
|
||||
# breadcrumb, so already-walked images aren't re-served as neighbours.
|
||||
try:
|
||||
reach = max(0.0, min(1.0, float(request.args.get("reach", "0"))))
|
||||
except ValueError:
|
||||
reach = 0.0
|
||||
exclude_ids = [
|
||||
int(x) for x in request.args.get("exclude_ids", "").split(",")
|
||||
if x.strip().isdigit()
|
||||
] or None
|
||||
# post_id is the exclusive post-detail view — not a similarity scope.
|
||||
# include_hidden is a gallery-browse flag; similar() has its OWN presentation
|
||||
# exclusion (a similarity-quality concern, #1274), so drop it here (#141).
|
||||
@@ -158,7 +169,8 @@ async def similar():
|
||||
svc = GalleryService(session)
|
||||
try:
|
||||
images = await svc.similar(
|
||||
image_id=similar_to, limit=limit, exclude_wip=exclude_wip, **scope)
|
||||
image_id=similar_to, limit=limit, exclude_wip=exclude_wip,
|
||||
reach=reach, exclude_ids=exclude_ids, **scope)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if images is None:
|
||||
@@ -236,8 +248,10 @@ async def jump():
|
||||
# content", surfaced in the gallery's Show-hidden review strip. -----------
|
||||
@gallery_bp.route("/hidden-review", methods=["GET"])
|
||||
async def hidden_review():
|
||||
"""Unresolved presentation auto-hide flags, most-concerning first (highest
|
||||
content score) — for the gallery's Hidden-view review strip."""
|
||||
"""Unresolved system-tag auto-apply review flags (chrome + process, #1464),
|
||||
most-concerning first (highest content score) — for the review strip. `mode`
|
||||
tells the client whether the flagged tag hid the image ('chrome') or left it
|
||||
visible ('process'), which decides the resolve labels (un-hide vs remove-tag)."""
|
||||
ptag = aliased(Tag)
|
||||
ctag = aliased(Tag)
|
||||
async with get_session() as session:
|
||||
@@ -247,6 +261,7 @@ async def hidden_review():
|
||||
PresentationReview.tag_id,
|
||||
PresentationReview.conflict_tag_id,
|
||||
PresentationReview.conflict_score,
|
||||
PresentationReview.mode,
|
||||
ImageRecord.path, ImageRecord.thumbnail_path,
|
||||
ImageRecord.sha256, ImageRecord.mime,
|
||||
ptag.name.label("tag_name"),
|
||||
@@ -266,6 +281,7 @@ async def hidden_review():
|
||||
"conflict_tag_id": r.conflict_tag_id,
|
||||
"conflict_name": r.conflict_name,
|
||||
"conflict_score": r.conflict_score,
|
||||
"mode": r.mode,
|
||||
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
|
||||
"image_url": image_url(r.path),
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ _EDITABLE = (
|
||||
"presentation_auto_apply_enabled",
|
||||
"presentation_auto_apply_threshold",
|
||||
"presentation_conflict_threshold",
|
||||
"process_auto_apply_enabled",
|
||||
"process_auto_apply_threshold",
|
||||
"process_conflict_threshold",
|
||||
"embedder_model_name",
|
||||
"embedder_model_version",
|
||||
*_DETECTOR_FIELDS,
|
||||
@@ -102,6 +105,9 @@ async def get_settings():
|
||||
"presentation_auto_apply_enabled": s.presentation_auto_apply_enabled,
|
||||
"presentation_auto_apply_threshold": s.presentation_auto_apply_threshold,
|
||||
"presentation_conflict_threshold": s.presentation_conflict_threshold,
|
||||
"process_auto_apply_enabled": s.process_auto_apply_enabled,
|
||||
"process_auto_apply_threshold": s.process_auto_apply_threshold,
|
||||
"process_conflict_threshold": s.process_conflict_threshold,
|
||||
"embedder_model_name": s.embedder_model_name,
|
||||
**{f: getattr(s, f) for f in _DETECTOR_FIELDS},
|
||||
}
|
||||
@@ -162,6 +168,12 @@ def _validate(p: dict) -> str | None:
|
||||
return "presentation_auto_apply_threshold must be between 0.5 and 0.999"
|
||||
if not (0.0 <= float(p["presentation_conflict_threshold"]) <= 1.0):
|
||||
return "presentation_conflict_threshold must be between 0 and 1"
|
||||
# Process auto-apply (#1464). wip/editor stay VISIBLE so a false apply is
|
||||
# low-harm (excludes-from-training + a review flag), but keep the same bar.
|
||||
if not (0.5 <= float(p["process_auto_apply_threshold"]) <= 0.999):
|
||||
return "process_auto_apply_threshold must be between 0.5 and 0.999"
|
||||
if not (0.0 <= float(p["process_conflict_threshold"]) <= 1.0):
|
||||
return "process_conflict_threshold must be between 0 and 1"
|
||||
# Embedder model swap (#1190): both must be non-empty. Changing them means a
|
||||
# different embedding space — the operator must re-embed + retrain after.
|
||||
for key in ("embedder_model_name", "embedder_model_version"):
|
||||
|
||||
@@ -49,6 +49,7 @@ _EDITABLE_FIELDS = (
|
||||
"translation_target_lang",
|
||||
"translation_min_confidence",
|
||||
"wip_title_tagging_enabled",
|
||||
"wip_soft_title_tagging_enabled",
|
||||
)
|
||||
|
||||
# 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_min_confidence": row.translation_min_confidence,
|
||||
"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(
|
||||
{"error": "wip_title_tagging_enabled must be a boolean"}
|
||||
), 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:
|
||||
row = await ImportSettings.load(session)
|
||||
|
||||
@@ -171,9 +171,19 @@ def make_celery() -> Celery:
|
||||
},
|
||||
"presentation-auto-apply-daily": {
|
||||
"task": "backend.app.tasks.ml.scheduled_presentation_auto_apply",
|
||||
"schedule": 86400.0, # auto-hide banner/editor chrome (#141);
|
||||
"schedule": 86400.0, # auto-hide banner chrome (#141);
|
||||
# no-op unless presentation_auto_apply_enabled
|
||||
},
|
||||
"process-auto-apply-daily": {
|
||||
"task": "backend.app.tasks.ml.scheduled_process_auto_apply",
|
||||
"schedule": 86400.0, # auto-tag wip/editor process art (#1464);
|
||||
# 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": {
|
||||
"task": "backend.app.tasks.ml.prune_presentation_reviews",
|
||||
"schedule": 86400.0, # retention: drop resolved review flags >30d
|
||||
|
||||
@@ -126,6 +126,13 @@ class ImportSettings(Base):
|
||||
wip_title_tagging_enabled: Mapped[bool] = mapped_column(
|
||||
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
|
||||
async def load(cls, session) -> ImportSettings:
|
||||
|
||||
@@ -85,12 +85,14 @@ class MLSettings(Base):
|
||||
Float, nullable=False, default=0.95
|
||||
)
|
||||
# -- Presentation chrome auto-hide (#141) -------------------------------
|
||||
# banner / editor screenshot auto-apply on the sweep with their OWN flat
|
||||
# threshold (decoupled from content-head graduation). Hiding is consequential
|
||||
# so it runs HIGH. `wip` is never auto-applied. When an image would be
|
||||
# auto-hidden but ALSO scores >= presentation_conflict_threshold on a content
|
||||
# head, it's still hidden but flagged for review (PresentationReview) instead
|
||||
# of buried silently. ON by default (opt-out); every auto-tag is reversible.
|
||||
# `banner` (chrome — clusters on UI, not content) auto-applies on the sweep
|
||||
# with its OWN flat threshold (decoupled from content-head graduation) and is
|
||||
# HIDDEN from the gallery. Hiding is consequential so it runs HIGH. When an
|
||||
# image would be auto-hidden but ALSO scores >= presentation_conflict_threshold
|
||||
# on a content head, it's still hidden but flagged for review
|
||||
# (PresentationReview, mode='chrome') instead of buried silently. ON by default
|
||||
# (opt-out); every auto-tag is reversible. NOTE (#1464): `wip` + `editor
|
||||
# screenshot` are no longer chrome — they went to the PROCESS path below.
|
||||
presentation_auto_apply_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
@@ -100,6 +102,26 @@ class MLSettings(Base):
|
||||
presentation_conflict_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.50
|
||||
)
|
||||
# -- Process auto-apply (#1464) ----------------------------------------
|
||||
# `wip` / `editor screenshot` are PROCESS art — unfinished pieces + program
|
||||
# screenshots that must stay OUT of head/CCIP training but, unlike chrome,
|
||||
# remain VISIBLE in the gallery (operator 2026-07-12). They auto-apply on the
|
||||
# sweep with their OWN flat threshold and a PROVISIONAL source (`process_auto`,
|
||||
# in training_data._AUTO_SOURCES) so the head NEVER trains on its own output —
|
||||
# it learns only from title (`wip_title`) + manual labels, which breaks the
|
||||
# runaway loop. When a process tag would be applied but the image ALSO scores
|
||||
# >= process_conflict_threshold on a content head, it's flagged for review
|
||||
# (PresentationReview, mode='process') rather than silently marked. OFF by
|
||||
# default — a new whole-library auto-tagger is opt-in; every auto-tag reversible.
|
||||
process_auto_apply_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
process_auto_apply_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.90
|
||||
)
|
||||
process_conflict_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.50
|
||||
)
|
||||
# Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069);
|
||||
# existing libraries keep their stored value until the operator re-embeds.
|
||||
embedder_model_version: Mapped[str] = mapped_column(
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""PresentationReview — an auto-hidden presentation tag that ALSO looked like
|
||||
real content, flagged for operator review (milestone 141).
|
||||
"""PresentationReview — a system-tag the auto-apply sweep applied that ALSO looked
|
||||
like real content, flagged for operator review (milestone 141 + #1464).
|
||||
|
||||
When the auto-apply sweep hides an image as chrome (banner / editor screenshot)
|
||||
but the image ALSO scores highly on a content head, it still hides it but records
|
||||
this row so the Hidden view can surface it ("⚠ also looks like <conflict tag>")
|
||||
for a keep-hidden / un-hide decision. Resolved rows are pruned by retention.
|
||||
When a sweep applies a system tag but the image ALSO scores highly on a content
|
||||
head, it still applies the tag but records this row so a review strip can surface
|
||||
it ("⚠ also looks like <conflict tag>"). Two modes (#1464): 'chrome' (banner —
|
||||
image is HIDDEN, review is keep-hidden / un-hide) and 'process' (wip / editor
|
||||
screenshot — image stays VISIBLE, review is confirm / remove-tag). Resolved rows
|
||||
are pruned by retention.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, func
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -31,6 +33,12 @@ class PresentationReview(Base):
|
||||
ForeignKey("tag.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
conflict_score: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
# Which sweep flagged this (#1464): 'chrome' (banner, hidden) or 'process'
|
||||
# (wip / editor screenshot, shown). Drives which review strip surfaces it and
|
||||
# what "resolve" means (un-hide vs remove-tag). Existing rows backfill 'chrome'.
|
||||
mode: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="chrome", server_default="chrome"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -43,14 +43,19 @@ class TagKind(StrEnum):
|
||||
# to keep historic tag rows queryable.
|
||||
|
||||
|
||||
# The seeded system tags (migration 0075). PRESENTATION tags additionally
|
||||
# hide from whole-image similarity results — they cluster on UI chrome, not
|
||||
# content. `wip` is real art: only the training pipelines exclude it.
|
||||
# The seeded system tags (migration 0075). Two behavior groups (#1464):
|
||||
# CHROME (banner): clusters on UI chrome, not content → HIDDEN from the default
|
||||
# gallery + from similarity; auto-applied via the sweep's chrome mode.
|
||||
# PROCESS (wip, editor screenshot): real-but-unfinished art / program screenshots
|
||||
# → SHOWN in the gallery (operator 2026-07-12), but excluded from the Explore
|
||||
# rabbit-hole; auto-applied via the sweep's process mode (provisional source,
|
||||
# ring-loud review guard).
|
||||
# ALL three are excluded from OTHER concepts' head/CCIP training (training-hygiene,
|
||||
# keyed on is_system); a system tag's OWN head trains on them — that's what makes
|
||||
# auto-flagging work.
|
||||
SYSTEM_TAG_NAMES = ("wip", "banner", "editor screenshot")
|
||||
PRESENTATION_SYSTEM_TAGS = ("banner", "editor screenshot")
|
||||
# `wip` marks real-but-unfinished art. It's kept in the gallery's own "similar"
|
||||
# results (#1274), but the Explore rabbit-hole opts to hide it (exclude_wip) so a
|
||||
# browse doesn't keep surfacing work-in-progress (operator, 2026-07-08).
|
||||
CHROME_SYSTEM_TAGS = ("banner",)
|
||||
PROCESS_SYSTEM_TAGS = ("wip", "editor screenshot")
|
||||
WIP_SYSTEM_TAG = "wip"
|
||||
|
||||
image_tag = Table(
|
||||
|
||||
@@ -31,7 +31,7 @@ from ..models import (
|
||||
Tag,
|
||||
TagPositiveConfirmation,
|
||||
)
|
||||
from ..models.tag import PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG, image_tag
|
||||
from ..models.tag import CHROME_SYSTEM_TAGS, PROCESS_SYSTEM_TAGS, image_tag
|
||||
from .pagination import decode_cursor, encode_cursor
|
||||
from .tag_query import (
|
||||
fandom_join_alias,
|
||||
@@ -396,6 +396,25 @@ def _diversify_similar(src, rows, limit, *, dup_threshold=8, lam=0.40):
|
||||
return [kept[i] for i in order]
|
||||
|
||||
|
||||
def _reach_sample(rows, limit, reach):
|
||||
"""From a distance-sorted candidate pool (nearest first), pick a spread of ranks
|
||||
that MIXES near (tag the current cluster) and mid-far (escape it) BEFORE dedup +
|
||||
MMR — the Explore "reach" dial (#1476).
|
||||
|
||||
reach in (0, 1]: the sampled span grows outward from the anchor (0.25→1.0 of the
|
||||
pool), evenly strided from rank 0 so the nearest are still represented. In a
|
||||
dense signature the nearest ranks are near-identical, so reaching farther is the
|
||||
only way to hand MMR genuinely different content — MMR alone can't escape a pool
|
||||
that's already all-near. reach<=0 or a small pool passes through unchanged."""
|
||||
n = len(rows)
|
||||
want = max(limit * 8, 100)
|
||||
if reach <= 0 or n <= want:
|
||||
return rows
|
||||
span = int(min(1.0, 0.25 + 0.75 * reach) * n)
|
||||
idx = sorted({min(int(i * span / want), n - 1) for i in range(want)})
|
||||
return [rows[i] for i in idx]
|
||||
|
||||
|
||||
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
|
||||
"""Map image_id -> {"name","slug"} via the canonical
|
||||
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
||||
@@ -419,16 +438,17 @@ class GalleryService:
|
||||
async def _hidden_tag_ids(
|
||||
self, include_hidden, tag_ids, tag_or_groups,
|
||||
) -> list[int] | None:
|
||||
"""Presentation-chrome tag ids to implicitly exclude from a gallery query,
|
||||
or None. None when the caller asked to include hidden, when the operator
|
||||
is explicitly filtering FOR a presentation tag (they clearly want to see
|
||||
it), or when no presentation tags exist. (milestone 141)"""
|
||||
"""Chrome (banner) tag ids to implicitly exclude from a gallery query, or
|
||||
None. None when the caller asked to include hidden, when the operator is
|
||||
explicitly filtering FOR a chrome tag (they clearly want to see it), or when
|
||||
no chrome tags exist. (milestone 141; #1464: editor screenshot is now PROCESS
|
||||
— shown — so only `banner` hides here.)"""
|
||||
if include_hidden:
|
||||
return None
|
||||
rows = await self.session.execute(
|
||||
select(Tag.id).where(
|
||||
Tag.is_system.is_(True),
|
||||
Tag.name.in_(PRESENTATION_SYSTEM_TAGS),
|
||||
Tag.name.in_(CHROME_SYSTEM_TAGS),
|
||||
)
|
||||
)
|
||||
pres = [r[0] for r in rows]
|
||||
@@ -716,6 +736,7 @@ class GalleryService:
|
||||
untagged: bool = False, no_artist: bool = False,
|
||||
date_from: datetime | None = None, date_to: datetime | None = None,
|
||||
exclude_wip: bool = False,
|
||||
reach: float = 0.0, exclude_ids: list[int] | None = None,
|
||||
) -> list[GalleryImage] | None:
|
||||
"""Visual "more like this": images near `image_id`'s SigLIP embedding
|
||||
(pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result
|
||||
@@ -744,20 +765,27 @@ class GalleryService:
|
||||
# wide pool there's nothing but the near-dupes to choose from. Widened
|
||||
# (5×→8×, cap 200→400) so the stronger MMR has genuinely distinct
|
||||
# neighbourhoods to reach into for more variance (operator, 2026-07-01).
|
||||
pool_n = min(400, max(limit * 8, 100))
|
||||
# Explore's reach>0 (#1476) widens it a LOT more: in a dense signature the
|
||||
# nearest few hundred are all near-identical, so far-enough candidates only
|
||||
# exist deeper in the ranked pool. _reach_sample then strides across them.
|
||||
if reach > 0:
|
||||
pool_n = min(1000, max(limit * 25, 100))
|
||||
else:
|
||||
pool_n = min(400, max(limit * 8, 100))
|
||||
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
|
||||
eff = _effective_date_col()
|
||||
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||
stmt = _outer_join_primary_post(stmt)
|
||||
# Presentation images (banner / editor-screenshot system tags, #128)
|
||||
# cluster on UI chrome rather than content, so near any one of them
|
||||
# they'd fill the grid. Excluded from CANDIDATES only — the anchor
|
||||
# itself may be a banner. `wip` stays surfaced here by default (real art;
|
||||
# only the training pipelines exclude it), but the Explore rabbit-hole
|
||||
# passes exclude_wip to also drop work-in-progress (operator, 2026-07-08).
|
||||
excluded_system_tags = PRESENTATION_SYSTEM_TAGS
|
||||
# Chrome (banner, #128) clusters on UI rather than content, so near any one
|
||||
# of them they'd fill the grid → excluded from CANDIDATES always (the anchor
|
||||
# itself may be a banner). PROCESS art (wip / editor screenshot) stays
|
||||
# surfaced here by default (real content; only the training pipelines exclude
|
||||
# it), but the Explore rabbit-hole passes exclude_wip to also drop the whole
|
||||
# process group so a browse doesn't keep surfacing work-in-progress
|
||||
# (operator, 2026-07-08; #1464 — editor now rides with wip here).
|
||||
excluded_system_tags = CHROME_SYSTEM_TAGS
|
||||
if exclude_wip:
|
||||
excluded_system_tags = (*PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG)
|
||||
excluded_system_tags = (*CHROME_SYSTEM_TAGS, *PROCESS_SYSTEM_TAGS)
|
||||
presentation = (
|
||||
select(image_tag.c.image_record_id)
|
||||
.join(Tag, Tag.id == image_tag.c.tag_id)
|
||||
@@ -771,6 +799,10 @@ class GalleryService:
|
||||
ImageRecord.id != image_id,
|
||||
ImageRecord.id.not_in(presentation),
|
||||
)
|
||||
# Anti-revisit (#1476): the Explore walk passes its breadcrumb so already-
|
||||
# walked images aren't re-served as neighbours — → can't loop you back in.
|
||||
if exclude_ids:
|
||||
stmt = stmt.where(ImageRecord.id.not_in(exclude_ids))
|
||||
stmt = _apply_scope(
|
||||
stmt, tag_ids=tag_ids, post_id=None,
|
||||
artist_id=artist_id, media_type=media_type,
|
||||
@@ -780,6 +812,10 @@ class GalleryService:
|
||||
)
|
||||
stmt = stmt.order_by(distance.asc()).limit(pool_n)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
# Explore reach: stride across an outward-growing distance span so the pool
|
||||
# handed to MMR spans near→mid-far, not just the tight cluster (#1476).
|
||||
if reach > 0:
|
||||
rows = _reach_sample(rows, limit, reach)
|
||||
rows = _diversify_similar(src, rows, limit)
|
||||
artists = await _artists_for(self.session, [r[0].id for r in rows])
|
||||
return _gallery_images(rows, artists)
|
||||
|
||||
@@ -47,7 +47,14 @@ from .attachment_store import AttachmentStore
|
||||
from .audits import single_color
|
||||
from .link_extract import extract_external_links
|
||||
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__)
|
||||
|
||||
@@ -999,7 +1006,9 @@ class Importer:
|
||||
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:
|
||||
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
|
||||
if record.primary_post_id is None:
|
||||
return
|
||||
@@ -1007,13 +1016,22 @@ class Importer:
|
||||
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):
|
||||
# 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
|
||||
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)
|
||||
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
|
||||
log.warning(
|
||||
"wip-title auto-tag failed for image %s: %s", record.id, exc
|
||||
|
||||
@@ -39,7 +39,7 @@ from ...models import (
|
||||
TagPositiveConfirmation,
|
||||
TagSuggestionRejection,
|
||||
)
|
||||
from ...models.tag import PRESENTATION_SYSTEM_TAGS, image_tag
|
||||
from ...models.tag import CHROME_SYSTEM_TAGS, PROCESS_SYSTEM_TAGS, image_tag
|
||||
from .training_data import (
|
||||
_AUTO_SOURCES,
|
||||
_auto_apply_point,
|
||||
@@ -759,18 +759,42 @@ def auto_apply_sweep(
|
||||
|
||||
|
||||
_PRESENTATION_SOURCE = "presentation_auto"
|
||||
_PROCESS_SOURCE = "process_auto"
|
||||
|
||||
# System-tag auto-apply modes (#1464). Both modes run the identical sweep — apply
|
||||
# a system tag at a flat threshold with a PROVISIONAL source + a ring-loud review
|
||||
# guard — and differ ONLY in which tags, which settings knobs, and which
|
||||
# source/review-mode. 'chrome' (banner) is HIDDEN from the gallery; 'process'
|
||||
# (wip / editor screenshot) stays VISIBLE (the hide is a gallery-query effect of
|
||||
# the tag's group membership, not of this sweep).
|
||||
_SWEEP_MODES = {
|
||||
"chrome": {
|
||||
"names": CHROME_SYSTEM_TAGS,
|
||||
"enabled": "presentation_auto_apply_enabled",
|
||||
"threshold": "presentation_auto_apply_threshold",
|
||||
"conflict": "presentation_conflict_threshold",
|
||||
"source": _PRESENTATION_SOURCE,
|
||||
},
|
||||
"process": {
|
||||
"names": PROCESS_SYSTEM_TAGS,
|
||||
"enabled": "process_auto_apply_enabled",
|
||||
"threshold": "process_auto_apply_threshold",
|
||||
"conflict": "process_conflict_threshold",
|
||||
"source": _PROCESS_SOURCE,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _presentation_heads(session: Session, embedding_version: str):
|
||||
"""Trained heads for the presentation chrome tags (banner / editor screenshot).
|
||||
They fire at the FLAT presentation threshold regardless of graduation — a head
|
||||
exists once the operator has labelled enough chrome (head_min_positives)."""
|
||||
def _system_tag_heads(session: Session, embedding_version: str, names):
|
||||
"""Trained heads for a system-tag group (chrome banner / process wip+editor).
|
||||
They fire at the group's FLAT threshold regardless of graduation — a head
|
||||
exists once the operator has labelled enough (head_min_positives)."""
|
||||
return session.execute(
|
||||
select(TagHead.tag_id, Tag.name, TagHead.weights, TagHead.bias)
|
||||
.join(Tag, Tag.id == TagHead.tag_id)
|
||||
.where(TagHead.embedding_version == embedding_version)
|
||||
.where(Tag.is_system.is_(True))
|
||||
.where(Tag.name.in_(PRESENTATION_SYSTEM_TAGS))
|
||||
.where(Tag.name.in_(names))
|
||||
).all()
|
||||
|
||||
|
||||
@@ -802,27 +826,33 @@ def _valued_image_ids(session: Session) -> set[int]:
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> dict:
|
||||
"""Auto-hide presentation chrome (banner / editor screenshot) at the FLAT
|
||||
presentation threshold (#141) — NOT the per-head graduated threshold. Two
|
||||
guards keep it safe: (1) never hide an image carrying a human/confirmed content
|
||||
tag; (2) if an image about to be hidden ALSO scores >= the conflict threshold
|
||||
on a content head, still hide it but flag it (PresentationReview) so the Hidden
|
||||
view surfaces "also looks like <X>" for review. No-op unless
|
||||
presentation_auto_apply_enabled. numpy-only (no sklearn). Returns
|
||||
{n_applied, n_flagged, concepts}."""
|
||||
def system_tag_auto_apply_sweep(
|
||||
session: Session, *, mode: str, dry_run: bool = False
|
||||
) -> dict:
|
||||
"""Auto-apply a system-tag group at its FLAT threshold. mode='chrome' (banner,
|
||||
#141) hides the image; mode='process' (wip / editor screenshot, #1464) keeps it
|
||||
VISIBLE — the ONLY difference is the tag group's gallery membership, not this
|
||||
sweep. Two guards keep it safe: (1) never touch an image carrying a
|
||||
human/confirmed content tag; (2) if the image ALSO scores >= the conflict
|
||||
threshold on a content head, still apply but flag it (PresentationReview,
|
||||
mode=<mode>) so the review strip surfaces "also looks like <X>". The source is
|
||||
PROVISIONAL so the head never trains on its own output. No-op unless the mode's
|
||||
enabled flag is set. numpy-only (no sklearn). Returns {n_applied, n_flagged,
|
||||
concepts}."""
|
||||
import numpy as np
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
cfg = _SWEEP_MODES[mode]
|
||||
settings = _settings(session)
|
||||
if not dry_run and not settings.presentation_auto_apply_enabled:
|
||||
if not dry_run and not getattr(settings, cfg["enabled"]):
|
||||
return {"n_applied": 0, "n_flagged": 0, "concepts": []}
|
||||
ver = settings.embedder_model_version
|
||||
pres = _presentation_heads(session, ver)
|
||||
pres = _system_tag_heads(session, ver, cfg["names"])
|
||||
if not pres:
|
||||
return {"n_applied": 0, "n_flagged": 0, "concepts": []}
|
||||
thr = float(settings.presentation_auto_apply_threshold)
|
||||
conflict_thr = float(settings.presentation_conflict_threshold)
|
||||
thr = float(getattr(settings, cfg["threshold"]))
|
||||
conflict_thr = float(getattr(settings, cfg["conflict"]))
|
||||
source = cfg["source"]
|
||||
|
||||
Wp = np.vstack([np.asarray(r.weights, dtype=np.float32) for r in pres])
|
||||
bp = np.asarray([r.bias for r in pres], dtype=np.float32)
|
||||
@@ -884,11 +914,13 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
|
||||
pg_insert(image_tag)
|
||||
.values(
|
||||
image_record_id=iid, tag_id=tid,
|
||||
source=_PRESENTATION_SOURCE,
|
||||
source=source,
|
||||
)
|
||||
.on_conflict_do_nothing()
|
||||
)
|
||||
# Guard 2: also looks like content → hide but flag for review.
|
||||
# Guard 2: also looks like real content → still apply, but flag it
|
||||
# for the review strip instead of silently marking (chrome hides,
|
||||
# process stays visible — either way the operator gets a heads-up).
|
||||
if Wc is not None and float(max_c[idx]) >= conflict_thr:
|
||||
n_flagged += 1
|
||||
if not dry_run:
|
||||
@@ -898,6 +930,7 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
|
||||
image_record_id=iid, tag_id=tid,
|
||||
conflict_tag_id=conf_tag_ids[int(arg_c[idx])],
|
||||
conflict_score=float(max_c[idx]),
|
||||
mode=mode,
|
||||
)
|
||||
.on_conflict_do_nothing()
|
||||
)
|
||||
@@ -914,6 +947,74 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
"""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
|
||||
|
||||
@@ -29,7 +29,15 @@ from ...models.tag import image_tag
|
||||
# a CCIP reference) unless the operator confirms them (milestone 139). Keeping
|
||||
# auto-applied predictions out of training is what makes them "soft" — a misfire
|
||||
# can't reinforce itself, so the retraction sweep can actually drop it.
|
||||
_AUTO_SOURCES = ("head_auto", "ccip_auto", "ml_auto", "presentation_auto")
|
||||
# `process_auto` (#1464): wip/editor screenshot applied by the process sweep are
|
||||
# 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).
|
||||
# `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]:
|
||||
|
||||
@@ -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
|
||||
# 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
|
||||
@@ -39,11 +45,20 @@ _WIP_RE = re.compile(
|
||||
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.
|
||||
# MUST stay a SUPERSET of _WIP_RE (every regex match contains "wip" or
|
||||
# "work…progress") or the sweep would silently miss posts.
|
||||
# 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).
|
||||
@@ -51,12 +66,19 @@ _INSERT_CHUNK = 5000
|
||||
|
||||
|
||||
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:
|
||||
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(
|
||||
@@ -64,8 +86,10 @@ def resolve_wip_tag_id(session: Session) -> int | None:
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def apply_wip_image_tags(session: Session, image_ids, tag_id: int) -> int:
|
||||
"""Attach ``tag_id`` (source='wip_title') to each image id, idempotently —
|
||||
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.
|
||||
|
||||
@@ -92,7 +116,7 @@ def apply_wip_image_tags(session: Session, image_ids, tag_id: int) -> int:
|
||||
session.execute(
|
||||
pg_insert(image_tag)
|
||||
.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
|
||||
])
|
||||
.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
|
||||
|
||||
|
||||
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(
|
||||
name="backend.app.tasks.maintenance.backfill_wip_title_tags",
|
||||
# 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,
|
||||
)
|
||||
def backfill_wip_title_tags() -> int:
|
||||
"""Scan EXISTING posts for explicit WIP titles and apply the `wip` system tag
|
||||
to their images — the operator-triggered back-catalogue catch-up for
|
||||
title-based WIP tagging (task #1458). New imports are tagged live by the
|
||||
importer; this covers everything already in the library.
|
||||
"""Scan EXISTING posts for WIP titles and apply the `wip` system tag to their
|
||||
images — the operator-triggered back-catalogue catch-up (task #1458 hard tier +
|
||||
#1474 soft tier). New imports are tagged live by the importer; this covers the
|
||||
existing library.
|
||||
|
||||
Keyset-paginated over posts (restart-safe). A coarse SQL prefilter narrows to
|
||||
titles that COULD match; the precise regex (matches_wip_title) confirms.
|
||||
Idempotent-additive (ON CONFLICT DO NOTHING) — never disturbs an existing tag.
|
||||
HARD tier ("WIP"/"work in progress") always runs (the operator triggered the
|
||||
scan); the SOFT tier (sketch/doodle, provisional source) runs only when
|
||||
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
|
||||
matching posts and silently undo a manual WIP removal, so it stays an explicit
|
||||
operator action (Settings → "Scan existing posts for WIP titles"). Returns the
|
||||
number of image-tag rows newly applied.
|
||||
Deliberately NOT scheduled as a beat: a periodic re-run would re-apply to matching
|
||||
posts and silently undo a manual WIP removal, so it stays an explicit operator
|
||||
action (Settings → "Scan existing posts for WIP titles"). Returns rows applied.
|
||||
"""
|
||||
from ..models import Post
|
||||
from ..models.image_provenance import ImageProvenance
|
||||
from ..models import ImportSettings
|
||||
from ..services.wip_title import (
|
||||
SOFT_WIP_TITLE_SQL_PREFILTER,
|
||||
WIP_TITLE_SOFT_SOURCE,
|
||||
WIP_TITLE_SOURCE,
|
||||
WIP_TITLE_SQL_PREFILTER,
|
||||
apply_wip_image_tags,
|
||||
matches_soft_wip_title,
|
||||
matches_wip_title,
|
||||
resolve_wip_tag_id,
|
||||
)
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
applied = 0
|
||||
last_id = 0
|
||||
with SessionLocal() as session:
|
||||
tag_id = resolve_wip_tag_id(session)
|
||||
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"
|
||||
)
|
||||
return 0
|
||||
like_a, like_b = WIP_TITLE_SQL_PREFILTER
|
||||
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(like_a),
|
||||
Post.post_title.ilike(like_b),
|
||||
))
|
||||
.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()
|
||||
settings = ImportSettings.load_sync(session)
|
||||
applied = _backfill_wip_tier(
|
||||
session, tag_id, WIP_TITLE_SQL_PREFILTER, matches_wip_title,
|
||||
WIP_TITLE_SOURCE,
|
||||
)
|
||||
if settings.wip_soft_title_tagging_enabled:
|
||||
applied += _backfill_wip_tier(
|
||||
session, tag_id, SOFT_WIP_TITLE_SQL_PREFILTER, matches_soft_wip_title,
|
||||
WIP_TITLE_SOFT_SOURCE,
|
||||
)
|
||||
if applied:
|
||||
log.info("backfill_wip_title_tags: applied wip to %d image(s)", applied)
|
||||
return applied
|
||||
|
||||
+42
-6
@@ -599,18 +599,54 @@ def scheduled_ccip_auto_apply() -> str:
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def scheduled_presentation_auto_apply() -> str:
|
||||
"""Auto-hide presentation chrome (banner / editor screenshot) on a daily
|
||||
passive sweep (#141). No-op unless presentation_auto_apply_enabled. Idempotent
|
||||
— already-hidden images are skipped — so an interrupted run simply re-runs next
|
||||
cycle (that IS the recovery). Wall-clock bounded by the task time limits."""
|
||||
from ..services.ml.heads import presentation_auto_apply_sweep
|
||||
"""Auto-hide presentation chrome (banner) on a daily passive sweep (#141).
|
||||
No-op unless presentation_auto_apply_enabled. Idempotent — already-tagged images
|
||||
are skipped — so an interrupted run simply re-runs next cycle (that IS the
|
||||
recovery). Wall-clock bounded by the task time limits."""
|
||||
from ..services.ml.heads import system_tag_auto_apply_sweep
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
result = presentation_auto_apply_sweep(session)
|
||||
result = system_tag_auto_apply_sweep(session, mode="chrome")
|
||||
return f"applied={result['n_applied']} flagged={result['n_flagged']}"
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.ml.scheduled_process_auto_apply",
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def scheduled_process_auto_apply() -> str:
|
||||
"""Auto-apply the PROCESS system tags (wip / editor screenshot) on a daily
|
||||
passive sweep (#1464) — provisional source, ring-loud review guard, image stays
|
||||
VISIBLE. No-op unless process_auto_apply_enabled (opt-in). Idempotent —
|
||||
already-tagged/rejected images are skipped — so an interrupted run just re-runs
|
||||
next cycle (the recovery). Wall-clock bounded by the task time limits."""
|
||||
from ..services.ml.heads import system_tag_auto_apply_sweep
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
result = system_tag_auto_apply_sweep(session, mode="process")
|
||||
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")
|
||||
def prune_presentation_reviews() -> str:
|
||||
"""Retention (rule 89): drop RESOLVED presentation-review flags older than 30
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<template>
|
||||
<!-- Auto-hidden chrome that ALSO looked like real content — surfaced PROACTIVELY
|
||||
atop the gallery whenever there's something to review (NOT gated on the
|
||||
Show-hidden toggle, so misfires can't go unnoticed), most-concerning first,
|
||||
with keep / un-hide (#141). Renders nothing when there's nothing to review. -->
|
||||
<section v-if="items.length" class="fc-review" aria-label="Hidden images to review">
|
||||
<!-- System-tag auto-applies (chrome hides / process WIP tags) that ALSO looked
|
||||
like real content — surfaced PROACTIVELY atop the gallery whenever there's
|
||||
something to review (NOT gated on the Show-hidden toggle, so misfires can't
|
||||
go unnoticed), most-concerning first, with keep / remove (#141, #1464).
|
||||
Renders nothing when there's nothing to review. -->
|
||||
<section v-if="items.length" class="fc-review" aria-label="Auto-tagged images to review">
|
||||
<div class="fc-review__head">
|
||||
<v-icon size="18" color="warning">mdi-alert-outline</v-icon>
|
||||
<span class="fc-review__title">
|
||||
{{ items.length }} auto-hidden {{ items.length === 1 ? 'image' : 'images' }}
|
||||
may be real content — review before they stay hidden
|
||||
{{ items.length }} auto-tagged {{ items.length === 1 ? 'image' : 'images' }}
|
||||
may be real content — review
|
||||
</span>
|
||||
</div>
|
||||
<div class="fc-review__cards">
|
||||
@@ -26,16 +27,16 @@
|
||||
>
|
||||
also looks like <strong>{{ it.conflict_name || 'content' }}</strong>
|
||||
</div>
|
||||
<div class="fc-review-card__tag">hidden as {{ it.tag_name }}</div>
|
||||
<div class="fc-review-card__tag">{{ tagLine(it) }}</div>
|
||||
<div class="fc-review-card__acts">
|
||||
<button
|
||||
type="button" class="fc-review-btn fc-review-btn--keep"
|
||||
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'keep')"
|
||||
>Keep hidden</button>
|
||||
>{{ keepLabel(it) }}</button>
|
||||
<button
|
||||
type="button" class="fc-review-btn fc-review-btn--unhide"
|
||||
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'unhide')"
|
||||
>Un-hide</button>
|
||||
>{{ removeLabel(it) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,6 +55,11 @@ const items = ref([])
|
||||
const busy = ref([])
|
||||
|
||||
function keyOf(it) { return `${it.image_id}:${it.tag_id}` }
|
||||
// Chrome flags hide the image (keep-hidden / un-hide); process flags leave it
|
||||
// visible and just tagged (keep-tag / remove-tag). Same endpoints, different words.
|
||||
function tagLine(it) { return (it.mode === 'process' ? 'auto-tagged ' : 'hidden as ') + it.tag_name }
|
||||
function keepLabel(it) { return it.mode === 'process' ? 'Keep tag' : 'Keep hidden' }
|
||||
function removeLabel(it) { return it.mode === 'process' ? 'Remove tag' : 'Un-hide' }
|
||||
|
||||
async function load() {
|
||||
// Fetched unconditionally on mount — the strip prompts for pending misfires
|
||||
@@ -71,7 +77,8 @@ async function resolve(it, action) {
|
||||
await api.post(`/api/gallery/hidden-review/${it.image_id}/${it.tag_id}/${action}`)
|
||||
items.value = items.value.filter((x) => keyOf(x) !== k)
|
||||
if (action === 'unhide') {
|
||||
toast({ text: `Un-hidden — “${it.tag_name}” removed; it'll train the head`, type: 'success' })
|
||||
const verb = it.mode === 'process' ? 'Removed' : 'Un-hidden'
|
||||
toast({ text: `${verb} — “${it.tag_name}” removed; it'll train the head`, type: 'success' })
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
|
||||
@@ -171,13 +171,13 @@
|
||||
/>
|
||||
</div>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Auto-hide banners and editor screenshots from the gallery once a head has
|
||||
learned them (≥ {{ minPositives }} examples) and clears
|
||||
Auto-hide <code>banner</code> chrome from the gallery once a head has
|
||||
learned it (≥ {{ minPositives }} examples) and clears
|
||||
{{ Math.round((presentationThresholdInput || 0) * 100) }}% confidence.
|
||||
<code>wip</code> is never auto-hidden. If a hidden image also looks like
|
||||
real content (≥ {{ Math.round((presentationConflictInput || 0) * 100) }}%
|
||||
on a content tag), it's flagged in the Hidden view instead of buried.
|
||||
Every auto-hide is reversible.
|
||||
(<code>wip</code> and <code>editor screenshot</code> are handled by the
|
||||
process auto-tagger below.) If a hidden image also looks like real content
|
||||
(≥ {{ Math.round((presentationConflictInput || 0) * 100) }}% on a content
|
||||
tag), it's flagged for review instead of buried. Every auto-hide is reversible.
|
||||
</p>
|
||||
<div class="d-flex mb-3" style="gap: 12px;">
|
||||
<v-text-field
|
||||
@@ -195,6 +195,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Process auto-tagging (#1464): wip / editor screenshot -->
|
||||
<div class="fc-auto mt-6">
|
||||
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
||||
<v-icon size="18" color="accent">mdi-progress-wrench</v-icon>
|
||||
<span class="fc-section-h">Auto-tag work-in-progress</span>
|
||||
<v-switch
|
||||
v-model="processEnabled" :loading="settingBusy" hide-details
|
||||
density="compact" color="success" class="ml-auto"
|
||||
@update:model-value="onToggleProcess"
|
||||
/>
|
||||
</div>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Auto-tag <code>wip</code> and <code>editor screenshot</code> process art
|
||||
once a head has learned them (≥ {{ minPositives }} examples) and clears
|
||||
{{ Math.round((processThresholdInput || 0) * 100) }}% confidence. These stay
|
||||
<strong>visible</strong> in the gallery — the tag just keeps them out of
|
||||
training and the Explore rabbit-hole. Off by default. If a tagged image also
|
||||
looks like real content (≥ {{ Math.round((processConflictInput || 0) * 100) }}%
|
||||
on a content tag), it's flagged for review. Learns only from your titles +
|
||||
manual tags, never its own guesses — so it can't run away. Every tag reversible.
|
||||
</p>
|
||||
<div class="d-flex mb-3" style="gap: 12px;">
|
||||
<v-text-field
|
||||
v-model.number="processThresholdInput" label="Tag confidence"
|
||||
type="number" min="0.5" max="0.999" step="0.01" density="compact"
|
||||
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
||||
@change="onSaveProcess"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="processConflictInput" label="Flag if content ≥"
|
||||
type="number" min="0" max="1" step="0.05" density="compact"
|
||||
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
||||
@change="onSaveProcess"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance / tuning -->
|
||||
<div v-if="metricsConcepts.length" class="mt-5">
|
||||
<div class="fc-section-h mb-1">How auto-apply is landing</div>
|
||||
@@ -258,6 +295,9 @@ let autoTimer = null
|
||||
const presentationEnabled = ref(true)
|
||||
const presentationThresholdInput = ref(0.90)
|
||||
const presentationConflictInput = ref(0.50)
|
||||
const processEnabled = ref(false)
|
||||
const processThresholdInput = ref(0.90)
|
||||
const processConflictInput = ref(0.50)
|
||||
|
||||
const autoRunning = computed(() => autoStatus.value?.running_id != null)
|
||||
const lastSweep = computed(() =>
|
||||
@@ -292,6 +332,9 @@ onMounted(async () => {
|
||||
presentationEnabled.value = s.presentation_auto_apply_enabled ?? true
|
||||
presentationThresholdInput.value = s.presentation_auto_apply_threshold ?? 0.90
|
||||
presentationConflictInput.value = s.presentation_conflict_threshold ?? 0.50
|
||||
processEnabled.value = s.process_auto_apply_enabled ?? false
|
||||
processThresholdInput.value = s.process_auto_apply_threshold ?? 0.90
|
||||
processConflictInput.value = s.process_conflict_threshold ?? 0.50
|
||||
} catch { /* non-fatal */ }
|
||||
await refresh()
|
||||
if (running.value) startPoll()
|
||||
@@ -402,6 +445,32 @@ async function onSavePresentation() {
|
||||
settingBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleProcess(val) {
|
||||
settingBusy.value = true
|
||||
try {
|
||||
await mlSettings.patchSettings({ process_auto_apply_enabled: !!val })
|
||||
toast({ text: val ? 'WIP auto-tag on' : 'WIP auto-tag off', type: 'success' })
|
||||
} catch (e) {
|
||||
processEnabled.value = !val // revert the switch
|
||||
toast({ text: `Could not update: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
settingBusy.value = false
|
||||
}
|
||||
}
|
||||
async function onSaveProcess() {
|
||||
settingBusy.value = true
|
||||
try {
|
||||
await mlSettings.patchSettings({
|
||||
process_auto_apply_threshold: Number(processThresholdInput.value),
|
||||
process_conflict_threshold: Number(processConflictInput.value),
|
||||
})
|
||||
} catch (e) {
|
||||
toast({ text: `Could not save: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
settingBusy.value = false
|
||||
}
|
||||
}
|
||||
function onPreview() { startSweep(true) }
|
||||
function onApplyNow() { startSweep(false) }
|
||||
async function startSweep(dryRun) {
|
||||
|
||||
@@ -99,6 +99,17 @@
|
||||
the Explore browse. Applies to new imports; run the scan below to catch
|
||||
posts already in your library.
|
||||
</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
|
||||
variant="tonal" color="primary" size="small"
|
||||
:loading="store.wipScanBusy" prepend-icon="mdi-magnify"
|
||||
@@ -139,6 +150,7 @@ const local = reactive({
|
||||
skip_single_color: false, single_color_threshold: 0.95,
|
||||
phash_threshold: 10,
|
||||
wip_title_tagging_enabled: true,
|
||||
wip_soft_title_tagging_enabled: false,
|
||||
})
|
||||
|
||||
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||
|
||||
@@ -25,6 +25,10 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
const cursor = ref(-1)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
// Reach (#1476): how far the walk reaches past the anchor's immediate cluster.
|
||||
// 0 = nearest (can get stuck in a dense signature); ~0.4 default mixes in
|
||||
// mid-far escape routes so the walk diversifies without hitting "Random image".
|
||||
const reach = ref(0.4)
|
||||
|
||||
const inflight = useInflightToken()
|
||||
|
||||
@@ -46,7 +50,13 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
const body = await api.get('/api/gallery/similar', {
|
||||
// exclude_wip: keep work-in-progress out of the Explore rabbit-hole
|
||||
// (the gallery's own "similar" button still shows it) — operator 2026-07-08.
|
||||
params: { similar_to: numId, limit: NEIGHBOR_LIMIT, exclude_wip: 1 },
|
||||
// reach + exclude_ids (#1476): reach past the dense cluster + never re-serve
|
||||
// an already-walked image, so the walk keeps moving instead of getting stuck.
|
||||
params: {
|
||||
similar_to: numId, limit: NEIGHBOR_LIMIT, exclude_wip: 1,
|
||||
reach: reach.value,
|
||||
exclude_ids: breadcrumb.value.map((c) => c.id).join(','),
|
||||
},
|
||||
})
|
||||
if (!t.isCurrent()) return
|
||||
neighbors.value = body.images || []
|
||||
@@ -113,6 +123,14 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// Change how far the walk reaches and re-fetch the current anchor's neighbours
|
||||
// with the new setting (the anchor + trail are unchanged — only the grid varies).
|
||||
function setReach (v) {
|
||||
reach.value = Math.max(0, Math.min(1, Number(v)))
|
||||
const id = anchor.value?.id
|
||||
if (id != null) anchorOn(id)
|
||||
}
|
||||
|
||||
// --- TagPanel "host" surface ---------------------------------------------
|
||||
// The anchor IS the current image (same /api/gallery/image/<id> payload the
|
||||
// modal uses), so these mirror the modal store's tag-CRUD, targeting the
|
||||
@@ -189,8 +207,8 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
function close () {}
|
||||
|
||||
return {
|
||||
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT,
|
||||
anchorOn, reset, backTarget, forwardTarget,
|
||||
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT, reach,
|
||||
anchorOn, reset, backTarget, forwardTarget, setReach,
|
||||
// host surface
|
||||
current, currentImageId,
|
||||
reloadTags, addExistingTag, removeTag, createAndAdd, close,
|
||||
|
||||
@@ -31,6 +31,20 @@
|
||||
<img :src="c.thumbnail_url" alt="" loading="lazy" />
|
||||
</button>
|
||||
<div class="fc-ex__trail-actions">
|
||||
<!-- Reach (#1476): how far each step reaches past the anchor's cluster.
|
||||
Raise it to break out of a dense signature without hitting Random. -->
|
||||
<div
|
||||
class="fc-ex__reach"
|
||||
title="How far each step reaches — raise it to escape a dense cluster without going fully random"
|
||||
>
|
||||
<v-icon size="16" color="accent">mdi-map-marker-distance</v-icon>
|
||||
<v-slider
|
||||
:model-value="store.reach" @end="store.setReach"
|
||||
:min="0" :max="1" :step="0.2" hide-details density="compact"
|
||||
color="accent" class="fc-ex__reach-slider"
|
||||
/>
|
||||
<span class="fc-muted fc-ex__reach-label">{{ reachLabel }}</span>
|
||||
</div>
|
||||
<!-- Active retrain right where you tag: fold the +/- you just gave
|
||||
into the heads without a trip to Settings (the nightly beat is the
|
||||
passive cadence). -->
|
||||
@@ -153,6 +167,12 @@ const modal = useModalStore()
|
||||
|
||||
const anchorId = computed(() => route.params.imageId || null)
|
||||
const isVideo = computed(() => !!store.anchor?.mime?.startsWith('video/'))
|
||||
const reachLabel = computed(() => {
|
||||
const r = store.reach
|
||||
if (r <= 0.15) return 'Near'
|
||||
if (r <= 0.55) return 'Varied'
|
||||
return 'Far'
|
||||
})
|
||||
|
||||
// #1206: hovering a suggestion in the rail highlights the crop it came from on
|
||||
// the anchor image (same provide/inject as the modal viewer).
|
||||
@@ -296,6 +316,14 @@ onUnmounted(() => {
|
||||
.fc-ex__trail-actions {
|
||||
margin-left: auto; display: flex; align-items: center; gap: 4px; flex: 0 0 auto;
|
||||
}
|
||||
.fc-ex__reach {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.fc-ex__reach-slider { width: 96px; }
|
||||
.fc-ex__reach-label {
|
||||
font-size: 12px; min-width: 44px; text-align: left;
|
||||
}
|
||||
|
||||
/* The three panes fill the remaining height; each scrolls on its own.
|
||||
grid-template-rows: minmax(0, 1fr) BOUNDS the single row to the container
|
||||
|
||||
@@ -82,24 +82,28 @@ async def _system_tag(db, name):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_hides_presentation_chrome_by_default(db):
|
||||
# banner / editor screenshot (presentation system tags) are hidden from the
|
||||
# default gallery; wip (also a system tag) is NOT — it's real, in-progress
|
||||
# art (milestone 141). Seeded system tags survive the harness TRUNCATE.
|
||||
imgs = await _seed_images(db, 3, sha_prefix="p")
|
||||
# banner (chrome) is hidden from the default gallery; wip AND editor screenshot
|
||||
# (the PROCESS system tags) are NOT — they're real art / process shots that stay
|
||||
# visible (milestone 141 + #1464). Seeded system tags survive the harness TRUNCATE.
|
||||
imgs = await _seed_images(db, 4, sha_prefix="p")
|
||||
banner = await _system_tag(db, "banner")
|
||||
wip = await _system_tag(db, "wip")
|
||||
editor = await _system_tag(db, "editor screenshot")
|
||||
await db.execute(image_tag.insert().values(
|
||||
image_record_id=imgs[0].id, tag_id=banner.id, source="manual"))
|
||||
await db.execute(image_tag.insert().values(
|
||||
image_record_id=imgs[1].id, tag_id=wip.id, source="manual"))
|
||||
await db.execute(image_tag.insert().values(
|
||||
image_record_id=imgs[3].id, tag_id=editor.id, source="manual"))
|
||||
await db.flush()
|
||||
svc = GalleryService(db)
|
||||
|
||||
# Default: the banner image is hidden; the wip image + the plain image stay.
|
||||
# Default: only the banner image is hidden; wip + editor + plain all stay.
|
||||
default_ids = {i.id for i in (await svc.scroll(cursor=None, limit=10)).images}
|
||||
assert imgs[0].id not in default_ids # banner hidden
|
||||
assert imgs[1].id in default_ids # wip visible
|
||||
assert imgs[2].id in default_ids # plain visible
|
||||
assert imgs[3].id in default_ids # editor screenshot visible (#1464)
|
||||
|
||||
# include_hidden surfaces the banner image (the Hidden view).
|
||||
shown = {i.id for i in (
|
||||
|
||||
@@ -170,6 +170,23 @@ async def test_similar_respects_limit(db):
|
||||
assert len(res) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_similar_exclude_ids_drops_walked(db):
|
||||
"""Explore passes its breadcrumb as exclude_ids so already-walked images
|
||||
aren't re-served as neighbours (#1476). reach>0 runs cleanly too (small pool
|
||||
→ the sampler passes through)."""
|
||||
src = await _img(db, 1, _vec(1, 0))
|
||||
walked = await _img(db, 2, _vec(1, 0.05))
|
||||
fresh = await _img(db, 3, _vec(1, 0.3))
|
||||
svc = GalleryService(db)
|
||||
res = await svc.similar(src.id, limit=10, exclude_ids=[walked.id])
|
||||
ids = {i.id for i in res}
|
||||
assert walked.id not in ids
|
||||
assert fresh.id in ids
|
||||
res_reach = await svc.similar(src.id, limit=10, reach=1.0)
|
||||
assert fresh.id in {i.id for i in res_reach}
|
||||
|
||||
|
||||
# --- API ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -15,7 +15,7 @@ from backend.app.models import (
|
||||
from backend.app.models.tag import image_tag
|
||||
from backend.app.services.ml.heads import (
|
||||
auto_apply_sweep,
|
||||
presentation_auto_apply_sweep,
|
||||
system_tag_auto_apply_sweep,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
@@ -70,7 +70,7 @@ def test_presentation_sweep_hides_chrome(db_sync):
|
||||
_head(db_sync, banner.id, 0, weight=3.0)
|
||||
img = _img(db_sync, "a" * 64, _emb(0))
|
||||
db_sync.commit()
|
||||
res = presentation_auto_apply_sweep(db_sync)
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
|
||||
assert res["n_applied"] == 1
|
||||
assert _source(db_sync, img.id, banner.id) == "presentation_auto"
|
||||
|
||||
@@ -86,7 +86,7 @@ def test_presentation_sweep_hard_skips_valued_image(db_sync):
|
||||
db_sync.execute(image_tag.insert().values(
|
||||
image_record_id=img.id, tag_id=content.id, source="manual"))
|
||||
db_sync.commit()
|
||||
res = presentation_auto_apply_sweep(db_sync)
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
|
||||
assert res["n_applied"] == 0
|
||||
assert _source(db_sync, img.id, banner.id) is None
|
||||
|
||||
@@ -102,7 +102,7 @@ def test_presentation_sweep_flags_conflict(db_sync):
|
||||
_head(db_sync, content.id, 0, weight=1.0) # content head also fires
|
||||
img = _img(db_sync, "c" * 64, _emb(0))
|
||||
db_sync.commit()
|
||||
res = presentation_auto_apply_sweep(db_sync)
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
|
||||
assert res["n_applied"] == 1
|
||||
assert res["n_flagged"] == 1
|
||||
assert _source(db_sync, img.id, banner.id) == "presentation_auto"
|
||||
@@ -123,7 +123,7 @@ def test_presentation_sweep_disabled_is_noop(db_sync):
|
||||
_head(db_sync, banner.id, 0, weight=3.0)
|
||||
img = _img(db_sync, "d" * 64, _emb(0))
|
||||
db_sync.commit()
|
||||
res = presentation_auto_apply_sweep(db_sync)
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
|
||||
assert res["n_applied"] == 0
|
||||
assert _source(db_sync, img.id, banner.id) is None
|
||||
|
||||
@@ -134,7 +134,7 @@ def test_presentation_sweep_ignores_wip(db_sync):
|
||||
_head(db_sync, wip.id, 0, weight=3.0)
|
||||
img = _img(db_sync, "e" * 64, _emb(0))
|
||||
db_sync.commit()
|
||||
res = presentation_auto_apply_sweep(db_sync)
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
|
||||
assert res["n_applied"] == 0
|
||||
assert _source(db_sync, img.id, wip.id) is None
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Process-group auto-apply sweep (#1464): wip / editor screenshot auto-tag at a
|
||||
flat threshold with a PROVISIONAL source (`process_auto`) so the head never trains
|
||||
on its own output, and stay VISIBLE (unlike chrome). Mirrors the chrome guards.
|
||||
numpy-only (no sklearn), tested directly via the sync session."""
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models import (
|
||||
ImageRecord,
|
||||
MLSettings,
|
||||
PresentationReview,
|
||||
Tag,
|
||||
TagHead,
|
||||
TagKind,
|
||||
)
|
||||
from backend.app.models.tag import image_tag
|
||||
from backend.app.services.ml.heads import system_tag_auto_apply_sweep
|
||||
from backend.app.services.ml.training_data import _ids_with_tag
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _emb(slot: int) -> list[float]:
|
||||
v = [0.0] * 1152
|
||||
v[slot] = 3.0
|
||||
return v
|
||||
|
||||
|
||||
def _img(db, sha: str, emb) -> ImageRecord:
|
||||
img = ImageRecord(
|
||||
path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg",
|
||||
width=1, height=1, origin="imported_filesystem",
|
||||
integrity_status="unknown", siglip_embedding=emb,
|
||||
)
|
||||
db.add(img)
|
||||
db.flush()
|
||||
return img
|
||||
|
||||
|
||||
def _head(db, tag_id: int, slot: int, *, weight=1.0):
|
||||
s = db.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one()
|
||||
w = [0.0] * 1152
|
||||
w[slot] = weight
|
||||
db.add(TagHead(
|
||||
tag_id=tag_id, embedding_version=s.embedder_model_version,
|
||||
weights=w, bias=0.0, suggest_threshold=0.5, auto_apply_threshold=0.5,
|
||||
n_pos=60, n_neg=90, ap=0.9, precision_cv=0.98, recall=0.7,
|
||||
))
|
||||
|
||||
|
||||
def _system_tag(db, name):
|
||||
return db.execute(
|
||||
select(Tag).where(Tag.is_system.is_(True), Tag.name == name)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def _enable_process(db):
|
||||
# process auto-apply is opt-in (default False) — turn it on for these tests.
|
||||
db.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one().process_auto_apply_enabled = True
|
||||
|
||||
|
||||
def _source(db, image_id, tag_id):
|
||||
return db.execute(
|
||||
select(image_tag.c.source)
|
||||
.where(image_tag.c.image_record_id == image_id)
|
||||
.where(image_tag.c.tag_id == tag_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def test_process_sweep_applies_wip_and_editor(db_sync):
|
||||
_enable_process(db_sync)
|
||||
wip = _system_tag(db_sync, "wip")
|
||||
editor = _system_tag(db_sync, "editor screenshot")
|
||||
_head(db_sync, wip.id, 0, weight=3.0)
|
||||
_head(db_sync, editor.id, 1, weight=3.0)
|
||||
w_img = _img(db_sync, "a" * 64, _emb(0))
|
||||
e_img = _img(db_sync, "b" * 64, _emb(1))
|
||||
db_sync.commit()
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="process")
|
||||
assert res["n_applied"] == 2
|
||||
assert _source(db_sync, w_img.id, wip.id) == "process_auto"
|
||||
assert _source(db_sync, e_img.id, editor.id) == "process_auto"
|
||||
|
||||
|
||||
def test_process_sweep_disabled_by_default_is_noop(db_sync):
|
||||
# process_auto_apply_enabled defaults False (opt-in) — no enable = no-op.
|
||||
wip = _system_tag(db_sync, "wip")
|
||||
_head(db_sync, wip.id, 0, weight=3.0)
|
||||
img = _img(db_sync, "c" * 64, _emb(0))
|
||||
db_sync.commit()
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="process")
|
||||
assert res["n_applied"] == 0
|
||||
assert _source(db_sync, img.id, wip.id) is None
|
||||
|
||||
|
||||
def test_process_sweep_skips_valued_image(db_sync):
|
||||
# Guard 1: never auto-apply to an image the operator already content-tagged.
|
||||
_enable_process(db_sync)
|
||||
wip = _system_tag(db_sync, "wip")
|
||||
_head(db_sync, wip.id, 0, weight=3.0)
|
||||
content = Tag(name="mychar", kind=TagKind.character)
|
||||
db_sync.add(content)
|
||||
db_sync.flush()
|
||||
img = _img(db_sync, "d" * 64, _emb(0))
|
||||
db_sync.execute(image_tag.insert().values(
|
||||
image_record_id=img.id, tag_id=content.id, source="manual"))
|
||||
db_sync.commit()
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="process")
|
||||
assert res["n_applied"] == 0
|
||||
assert _source(db_sync, img.id, wip.id) is None
|
||||
|
||||
|
||||
def test_process_sweep_flags_conflict_with_process_mode(db_sync):
|
||||
# Guard 2: also scores high on a content head → still applied, but flagged
|
||||
# for review with mode='process' (the ring-loud guard).
|
||||
_enable_process(db_sync)
|
||||
wip = _system_tag(db_sync, "wip")
|
||||
_head(db_sync, wip.id, 0, weight=3.0)
|
||||
content = Tag(name="looksreal", kind=TagKind.general)
|
||||
db_sync.add(content)
|
||||
db_sync.flush()
|
||||
_head(db_sync, content.id, 0, weight=1.0)
|
||||
img = _img(db_sync, "e" * 64, _emb(0))
|
||||
db_sync.commit()
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="process")
|
||||
assert res["n_applied"] == 1
|
||||
assert res["n_flagged"] == 1
|
||||
flag = db_sync.execute(
|
||||
select(PresentationReview).where(
|
||||
PresentationReview.image_record_id == img.id,
|
||||
PresentationReview.tag_id == wip.id,
|
||||
)
|
||||
).scalar_one()
|
||||
assert flag.mode == "process"
|
||||
assert flag.conflict_tag_id == content.id
|
||||
|
||||
|
||||
def test_process_auto_source_never_trains_head(db_sync):
|
||||
# The runaway break: provisional wip tags (process sweep 'process_auto', soft
|
||||
# title 'wip_title_soft') are NOT training positives; a HARD title-heuristic /
|
||||
# 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")
|
||||
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))
|
||||
db_sync.execute(image_tag.insert().values(
|
||||
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(
|
||||
image_record_id=title_img.id, tag_id=wip.id, source="wip_title"))
|
||||
db_sync.commit()
|
||||
positives = set(_ids_with_tag(db_sync, wip.id))
|
||||
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 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
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Explore reach sampler (#1476) — pure unit tests for `_reach_sample`, which picks
|
||||
a distance-rank spread so the neighbour pool handed to MMR spans near→mid-far and
|
||||
the walk can escape a dense cluster. No DB. `_reach_sample` only indexes rows, so
|
||||
plain ints stand in for the (ImageRecord, ...) tuples."""
|
||||
from backend.app.services.gallery_service import _reach_sample
|
||||
|
||||
|
||||
def test_reach_zero_or_negative_passes_through():
|
||||
rows = list(range(1000))
|
||||
assert _reach_sample(rows, 40, 0.0) is rows
|
||||
assert _reach_sample(rows, 40, -1.0) is rows
|
||||
|
||||
|
||||
def test_small_pool_passes_through():
|
||||
# n <= want (limit*8 = 320) → nothing to reach into.
|
||||
rows = list(range(50))
|
||||
assert _reach_sample(rows, 40, 1.0) is rows
|
||||
|
||||
|
||||
def test_higher_reach_reaches_deeper_ranks():
|
||||
rows = list(range(1000))
|
||||
near = _reach_sample(rows, 40, 0.2)
|
||||
far = _reach_sample(rows, 40, 1.0)
|
||||
# Both keep the nearest rank (stride starts at 0) so you can still tag the cluster.
|
||||
assert near[0] == 0
|
||||
assert far[0] == 0
|
||||
# But higher reach samples genuinely farther ranks.
|
||||
assert max(far) > max(near)
|
||||
assert max(far) >= 900 # reach=1 spans (almost) the whole pool
|
||||
assert max(near) <= 550 # reach=0.2 stays in the near half
|
||||
# Never runs past the pool.
|
||||
assert max(far) <= len(rows) - 1
|
||||
+28
-1
@@ -8,7 +8,7 @@ negative cases (substrings like ``swipe`` / ``wiped``) are the load-bearing ones
|
||||
"""
|
||||
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", [
|
||||
@@ -44,6 +44,33 @@ def test_matches_positive(title):
|
||||
"finished at last",
|
||||
"Kawips diner", # 'wip' mid-word
|
||||
"swipright",
|
||||
"quick sketch of Nami", # soft cue — NOT a HARD WIP match
|
||||
])
|
||||
def test_matches_negative(title):
|
||||
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.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
|
||||
from backend.app.services.wip_title import (
|
||||
WIP_TITLE_SOFT_SOURCE,
|
||||
apply_wip_image_tags,
|
||||
resolve_wip_tag_id,
|
||||
)
|
||||
|
||||
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.
|
||||
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