diff --git a/alembic/versions/0086_process_auto_apply_settings.py b/alembic/versions/0086_process_auto_apply_settings.py new file mode 100644 index 0000000..16f03c7 --- /dev/null +++ b/alembic/versions/0086_process_auto_apply_settings.py @@ -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") diff --git a/alembic/versions/0087_wip_soft_title_tagging.py b/alembic/versions/0087_wip_soft_title_tagging.py new file mode 100644 index 0000000..58478e1 --- /dev/null +++ b/alembic/versions/0087_wip_soft_title_tagging.py @@ -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") diff --git a/backend/app/api/gallery.py b/backend/app/api/gallery.py index 410852e..b5f8d1c 100644 --- a/backend/app/api/gallery.py +++ b/backend/app/api/gallery.py @@ -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), } diff --git a/backend/app/api/ml_admin.py b/backend/app/api/ml_admin.py index 52e26fd..6afff73 100644 --- a/backend/app/api/ml_admin.py +++ b/backend/app/api/ml_admin.py @@ -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"): diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index df372ea..d2d916a 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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) diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index f657af3..d8983ed 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -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 diff --git a/backend/app/models/import_settings.py b/backend/app/models/import_settings.py index c17ffd5..f4b8937 100644 --- a/backend/app/models/import_settings.py +++ b/backend/app/models/import_settings.py @@ -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: diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py index 0d38e91..574d16e 100644 --- a/backend/app/models/ml_settings.py +++ b/backend/app/models/ml_settings.py @@ -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( diff --git a/backend/app/models/presentation_review.py b/backend/app/models/presentation_review.py index d6f91c4..73da13f 100644 --- a/backend/app/models/presentation_review.py +++ b/backend/app/models/presentation_review.py @@ -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 ") -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 "). 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() ) diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py index c27ebd3..8d5a256 100644 --- a/backend/app/models/tag.py +++ b/backend/app/models/tag.py @@ -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( diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index 8d11a95..3f124db 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -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) diff --git a/backend/app/services/importer.py b/backend/app/services/importer.py index b773d89..db8cf0c 100644 --- a/backend/app/services/importer.py +++ b/backend/app/services/importer.py @@ -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 diff --git a/backend/app/services/ml/heads.py b/backend/app/services/ml/heads.py index 423d70d..ae5e7fc 100644 --- a/backend/app/services/ml/heads.py +++ b/backend/app/services/ml/heads.py @@ -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 " 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=) so the review strip surfaces "also looks like ". 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 ", 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 diff --git a/backend/app/services/ml/training_data.py b/backend/app/services/ml/training_data.py index 8fcf8cc..b88ebbd 100644 --- a/backend/app/services/ml/training_data.py +++ b/backend/app/services/ml/training_data.py @@ -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]: diff --git a/backend/app/services/wip_title.py b/backend/app/services/wip_title.py index 7ad13d4..49a18a4 100644 --- a/backend/app/services/wip_title.py +++ b/backend/app/services/wip_title.py @@ -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"(? 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"]) diff --git a/backend/app/tasks/maintenance.py b/backend/app/tasks/maintenance.py index 577213b..2ad0b3f 100644 --- a/backend/app/tasks/maintenance.py +++ b/backend/app/tasks/maintenance.py @@ -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 diff --git a/backend/app/tasks/ml.py b/backend/app/tasks/ml.py index 0f6343a..f25d0fe 100644 --- a/backend/app/tasks/ml.py +++ b/backend/app/tasks/ml.py @@ -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 diff --git a/frontend/src/components/gallery/HiddenReviewStrip.vue b/frontend/src/components/gallery/HiddenReviewStrip.vue index 78d0dd3..8a63786 100644 --- a/frontend/src/components/gallery/HiddenReviewStrip.vue +++ b/frontend/src/components/gallery/HiddenReviewStrip.vue @@ -1,14 +1,15 @@