Presentation-chrome auto-hide (milestone 141) + tagging-UX fixes #198

Merged
bvandeusen merged 11 commits from dev into main 2026-07-07 00:24:52 -04:00
23 changed files with 1203 additions and 22 deletions
@@ -0,0 +1,85 @@
"""presentation-chrome auto-hide (#141) — settings knobs + review table
MLSettings gains presentation_auto_apply_enabled / _threshold and
presentation_conflict_threshold: banner + editor-screenshot auto-hide on the
sweep with a FLAT threshold (decoupled from content-head graduation), and a
conflict threshold that flags an auto-hide that "also looks like content".
New table presentation_review records an auto-hidden chrome image that also
scored high on a content head, surfaced in the Hidden view for a keep-hidden /
un-hide decision. Resolved rows are pruned by retention.
Revision ID: 0082
Revises: 0081
Create Date: 2026-07-07
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0082"
down_revision: Union[str, None] = "0081"
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(
"presentation_auto_apply_enabled", sa.Boolean(), nullable=False,
server_default=sa.text("true"),
),
)
op.add_column(
"ml_settings",
sa.Column(
"presentation_auto_apply_threshold", sa.Float(), nullable=False,
server_default=sa.text("0.90"),
),
)
op.add_column(
"ml_settings",
sa.Column(
"presentation_conflict_threshold", sa.Float(), nullable=False,
server_default=sa.text("0.50"),
),
)
op.create_table(
"presentation_review",
sa.Column(
"image_record_id", sa.Integer(),
sa.ForeignKey("image_record.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"tag_id", sa.Integer(),
sa.ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True,
),
sa.Column(
"conflict_tag_id", sa.Integer(),
sa.ForeignKey("tag.id", ondelete="SET NULL"), nullable=True,
),
sa.Column("conflict_score", sa.Float(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
)
# The review list queries the unresolved flags (resolved_at IS NULL).
op.create_index(
"ix_presentation_review_resolved_at", "presentation_review",
["resolved_at"],
)
def downgrade() -> None:
op.drop_index(
"ix_presentation_review_resolved_at", table_name="presentation_review"
)
op.drop_table("presentation_review")
op.drop_column("ml_settings", "presentation_conflict_threshold")
op.drop_column("ml_settings", "presentation_auto_apply_threshold")
op.drop_column("ml_settings", "presentation_auto_apply_enabled")
+110 -2
View File
@@ -3,9 +3,18 @@
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from sqlalchemy import delete, select, update
from sqlalchemy.orm import aliased
from ..extensions import get_session from ..extensions import get_session
from ..services.gallery_service import GalleryService from ..models import (
ImageRecord,
PresentationReview,
Tag,
TagSuggestionRejection,
)
from ..models.tag import image_tag
from ..services.gallery_service import GalleryService, image_url, thumbnail_url
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery") gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
@@ -77,6 +86,9 @@ def _parse_filters():
platform = request.args.get("platform") or None platform = request.args.get("platform") or None
untagged = request.args.get("untagged") in ("1", "true", "yes") untagged = request.args.get("untagged") in ("1", "true", "yes")
no_artist = request.args.get("no_artist") in ("1", "true", "yes") no_artist = request.args.get("no_artist") in ("1", "true", "yes")
# Show the presentation chrome (banner / editor screenshot) that the default
# gallery hides — the Hidden view sets this (milestone 141).
include_hidden = request.args.get("include_hidden") in ("1", "true", "yes")
date_from = _parse_date(request.args.get("date_from")) date_from = _parse_date(request.args.get("date_from"))
date_to = _parse_date(request.args.get("date_to")) date_to = _parse_date(request.args.get("date_to"))
if date_to is not None: if date_to is not None:
@@ -88,6 +100,7 @@ def _parse_filters():
"platform": platform, "platform": platform,
"untagged": untagged, "no_artist": no_artist, "untagged": untagged, "no_artist": no_artist,
"date_from": date_from, "date_to": date_to, "date_from": date_from, "date_to": date_to,
"include_hidden": include_hidden,
} }
return filters, sort return filters, sort
@@ -133,7 +146,11 @@ async def similar():
except (KeyError, ValueError): except (KeyError, ValueError):
return jsonify({"error": "similar_to query param required"}), 400 return jsonify({"error": "similar_to query param required"}), 400
# post_id is the exclusive post-detail view — not a similarity scope. # post_id is the exclusive post-detail view — not a similarity scope.
scope = {k: v for k, v in filters.items() if k != "post_id"} # include_hidden is a gallery-browse flag; similar() has its OWN presentation
# exclusion (a similarity-quality concern, #1274), so drop it here (#141).
scope = {
k: v for k, v in filters.items() if k not in ("post_id", "include_hidden")
}
async with get_session() as session: async with get_session() as session:
svc = GalleryService(session) svc = GalleryService(session)
try: try:
@@ -211,6 +228,97 @@ async def jump():
return jsonify({"cursor": cursor}) return jsonify({"cursor": cursor})
# -- Hidden-view review (#141): auto-hidden chrome flagged "also looks like
# 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."""
ptag = aliased(Tag)
ctag = aliased(Tag)
async with get_session() as session:
rows = (await session.execute(
select(
PresentationReview.image_record_id,
PresentationReview.tag_id,
PresentationReview.conflict_tag_id,
PresentationReview.conflict_score,
ImageRecord.path, ImageRecord.thumbnail_path,
ImageRecord.sha256, ImageRecord.mime,
ptag.name.label("tag_name"),
ctag.name.label("conflict_name"),
)
.join(ImageRecord, ImageRecord.id == PresentationReview.image_record_id)
.join(ptag, ptag.id == PresentationReview.tag_id)
.outerjoin(ctag, ctag.id == PresentationReview.conflict_tag_id)
.where(PresentationReview.resolved_at.is_(None))
.order_by(PresentationReview.conflict_score.desc())
)).all()
return jsonify({"items": [
{
"image_id": r.image_record_id,
"tag_id": r.tag_id,
"tag_name": r.tag_name,
"conflict_tag_id": r.conflict_tag_id,
"conflict_name": r.conflict_name,
"conflict_score": r.conflict_score,
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
"image_url": image_url(r.path),
}
for r in rows
]})
@gallery_bp.route(
"/hidden-review/<int:image_id>/<int:tag_id>/keep", methods=["POST"]
)
async def hidden_review_keep(image_id, tag_id):
"""Keep the auto-hide: resolve the flag; the tag stays applied (#141)."""
async with get_session() as session:
await session.execute(
update(PresentationReview)
.where(
PresentationReview.image_record_id == image_id,
PresentationReview.tag_id == tag_id,
)
.values(resolved_at=datetime.now(UTC))
)
await session.commit()
return "", 204
@gallery_bp.route(
"/hidden-review/<int:image_id>/<int:tag_id>/unhide", methods=["POST"]
)
async def hidden_review_unhide(image_id, tag_id):
"""Un-hide: remove the presentation tag (image returns to the gallery), record
a rejection so the head LEARNS it misfired, and resolve the flag (#141)."""
from sqlalchemy.dialects.postgresql import insert as pg_insert
async with get_session() as session:
await session.execute(
delete(image_tag).where(
image_tag.c.image_record_id == image_id,
image_tag.c.tag_id == tag_id,
)
)
await session.execute(
pg_insert(TagSuggestionRejection)
.values(image_record_id=image_id, tag_id=tag_id)
.on_conflict_do_nothing()
)
await session.execute(
update(PresentationReview)
.where(
PresentationReview.image_record_id == image_id,
PresentationReview.tag_id == tag_id,
)
.values(resolved_at=datetime.now(UTC))
)
await session.commit()
return "", 204
@gallery_bp.route("/image/<int:image_id>", methods=["GET"]) @gallery_bp.route("/image/<int:image_id>", methods=["GET"])
async def image_detail(image_id: int): async def image_detail(image_id: int):
async with get_session() as session: async with get_session() as session:
+12
View File
@@ -39,6 +39,9 @@ _EDITABLE = (
"ccip_match_threshold", "ccip_match_threshold",
"ccip_auto_apply_enabled", "ccip_auto_apply_enabled",
"ccip_auto_apply_threshold", "ccip_auto_apply_threshold",
"presentation_auto_apply_enabled",
"presentation_auto_apply_threshold",
"presentation_conflict_threshold",
"embedder_model_name", "embedder_model_name",
"embedder_model_version", "embedder_model_version",
*_DETECTOR_FIELDS, *_DETECTOR_FIELDS,
@@ -96,6 +99,9 @@ async def get_settings():
"ccip_match_threshold": s.ccip_match_threshold, "ccip_match_threshold": s.ccip_match_threshold,
"ccip_auto_apply_enabled": s.ccip_auto_apply_enabled, "ccip_auto_apply_enabled": s.ccip_auto_apply_enabled,
"ccip_auto_apply_threshold": s.ccip_auto_apply_threshold, "ccip_auto_apply_threshold": s.ccip_auto_apply_threshold,
"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,
"embedder_model_name": s.embedder_model_name, "embedder_model_name": s.embedder_model_name,
**{f: getattr(s, f) for f in _DETECTOR_FIELDS}, **{f: getattr(s, f) for f in _DETECTOR_FIELDS},
} }
@@ -150,6 +156,12 @@ def _validate(p: dict) -> str | None:
return "ccip_match_threshold must be between 0.5 and 0.999" return "ccip_match_threshold must be between 0.5 and 0.999"
if not (0.5 <= float(p["ccip_auto_apply_threshold"]) <= 0.999): if not (0.5 <= float(p["ccip_auto_apply_threshold"]) <= 0.999):
return "ccip_auto_apply_threshold must be between 0.5 and 0.999" return "ccip_auto_apply_threshold must be between 0.5 and 0.999"
# Presentation chrome auto-hide (#141). Auto-apply runs high (hiding is
# consequential); the conflict cut is a plain probability [0,1].
if not (0.5 <= float(p["presentation_auto_apply_threshold"]) <= 0.999):
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"
# Embedder model swap (#1190): both must be non-empty. Changing them means a # Embedder model swap (#1190): both must be non-empty. Changing them means a
# different embedding space — the operator must re-embed + retrain after. # different embedding space — the operator must re-embed + retrain after.
for key in ("embedder_model_name", "embedder_model_version"): for key in ("embedder_model_name", "embedder_model_version"):
+9
View File
@@ -152,6 +152,15 @@ def make_celery() -> Celery:
"schedule": 86400.0, # soft auto-apply: drop auto-tags now below "schedule": 86400.0, # soft auto-apply: drop auto-tags now below
# their threshold (m139); no-op unless the auto-apply switch is on # their threshold (m139); no-op unless the auto-apply switch is on
}, },
"presentation-auto-apply-daily": {
"task": "backend.app.tasks.ml.scheduled_presentation_auto_apply",
"schedule": 86400.0, # auto-hide banner/editor chrome (#141);
# no-op unless presentation_auto_apply_enabled
},
"prune-presentation-reviews-daily": {
"task": "backend.app.tasks.ml.prune_presentation_reviews",
"schedule": 86400.0, # retention: drop resolved review flags >30d
},
"snapshot-head-metrics-daily": { "snapshot-head-metrics-daily": {
"task": "backend.app.tasks.maintenance.snapshot_head_metrics", "task": "backend.app.tasks.maintenance.snapshot_head_metrics",
"schedule": 86400.0, "schedule": 86400.0,
+2
View File
@@ -28,6 +28,7 @@ from .pixiv_failed_media import PixivFailedMedia
from .pixiv_seen_media import PixivSeenMedia from .pixiv_seen_media import PixivSeenMedia
from .post import Post from .post import Post
from .post_attachment import PostAttachment from .post_attachment import PostAttachment
from .presentation_review import PresentationReview
from .series_chapter import SeriesChapter from .series_chapter import SeriesChapter
from .series_page import SeriesPage from .series_page import SeriesPage
from .series_suggestion import SeriesSuggestion from .series_suggestion import SeriesSuggestion
@@ -57,6 +58,7 @@ __all__ = [
"SubscribeStarSeenMedia", "SubscribeStarSeenMedia",
"Post", "Post",
"PostAttachment", "PostAttachment",
"PresentationReview",
"SeriesChapter", "SeriesChapter",
"SeriesPage", "SeriesPage",
"SeriesSuggestion", "SeriesSuggestion",
+16
View File
@@ -84,6 +84,22 @@ class MLSettings(Base):
# character matches auto-tag. # character matches auto-tag.
Float, nullable=False, default=0.95 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.
presentation_auto_apply_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
presentation_auto_apply_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.90
)
presentation_conflict_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50
)
# Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069); # Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069);
# existing libraries keep their stored value until the operator re-embeds. # existing libraries keep their stored value until the operator re-embeds.
embedder_model_version: Mapped[str] = mapped_column( embedder_model_version: Mapped[str] = mapped_column(
+40
View File
@@ -0,0 +1,40 @@
"""PresentationReview — an auto-hidden presentation tag that ALSO looked like
real content, flagged for operator review (milestone 141).
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.
"""
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class PresentationReview(Base):
__tablename__ = "presentation_review"
image_record_id: Mapped[int] = mapped_column(
ForeignKey("image_record.id", ondelete="CASCADE"), primary_key=True
)
# The presentation tag that was auto-applied (banner / editor screenshot).
tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
)
# The content tag the image ALSO scored high on — the "concerning" signal.
# SET NULL (not CASCADE): losing the conflict tag shouldn't erase the flag.
conflict_tag_id: Mapped[int | None] = mapped_column(
ForeignKey("tag.id", ondelete="SET NULL"), nullable=True
)
conflict_score: Mapped[float] = mapped_column(Float, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Set when the operator keeps-hidden or un-hides; retention prunes resolved.
resolved_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
+52 -5
View File
@@ -187,7 +187,7 @@ def _apply_scope(
stmt, *, tag_ids, post_id, artist_id, media_type, stmt, *, tag_ids, post_id, artist_id, media_type,
tag_or_groups=None, tag_exclude=None, tag_or_groups=None, tag_exclude=None,
platform=None, untagged=False, no_artist=False, platform=None, untagged=False, no_artist=False,
date_from=None, date_to=None, date_from=None, date_to=None, hidden_tag_ids=None,
): ):
"""Apply the composable gallery filters to a statement. """Apply the composable gallery filters to a statement.
@@ -224,6 +224,12 @@ def _apply_scope(
stmt = stmt.where(image_in_any_tag_scope(group)) stmt = stmt.where(image_in_any_tag_scope(group))
if tag_exclude: if tag_exclude:
stmt = stmt.where(~image_in_any_tag_scope(tag_exclude)) stmt = stmt.where(~image_in_any_tag_scope(tag_exclude))
# Presentation chrome (banner / editor screenshot) is hidden from the default
# gallery — an implicit exclude the caller supplies unless the operator asked
# to include hidden or is explicitly filtering for a presentation tag
# (milestone 141). `wip` is NOT hidden. Resolved to ids by _hidden_tag_ids.
if hidden_tag_ids:
stmt = stmt.where(~image_in_any_tag_scope(hidden_tag_ids))
prov = _provenance_clause(post_id, artist_id) prov = _provenance_clause(post_id, artist_id)
if prov is not None: if prov is not None:
stmt = stmt.where(prov) stmt = stmt.where(prov)
@@ -410,6 +416,31 @@ class GalleryService:
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
self.session = session self.session = session
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)"""
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),
)
)
pres = [r[0] for r in rows]
if not pres:
return None
explicit = set(tag_ids or [])
for group in tag_or_groups or []:
explicit.update(group)
if explicit & set(pres):
return None
return pres
async def scroll( async def scroll(
self, self,
cursor: str | None, cursor: str | None,
@@ -426,12 +457,16 @@ class GalleryService:
no_artist: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_from: datetime | None = None,
date_to: datetime | None = None, date_to: datetime | None = None,
include_hidden: bool = False,
) -> GalleryPage: ) -> GalleryPage:
if limit < 1 or limit > 200: if limit < 1 or limit > 200:
raise ValueError("limit must be between 1 and 200") raise ValueError("limit must be between 1 and 200")
_require_single_filter( _require_single_filter(
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude, tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
) )
hidden = await self._hidden_tag_ids(
include_hidden, tag_ids, tag_or_groups,
)
# eff is the ACTIVE sort column (effective_date or earliest_post_date); # eff is the ACTIVE sort column (effective_date or earliest_post_date);
# the cursor, ordering and year/month grouping all key off it, so the # the cursor, ordering and year/month grouping all key off it, so the
@@ -444,7 +479,7 @@ class GalleryService:
artist_id=artist_id, media_type=media_type, artist_id=artist_id, media_type=media_type,
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude, tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
platform=platform, untagged=untagged, no_artist=no_artist, platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to, date_from=date_from, date_to=date_to, hidden_tag_ids=hidden,
) )
descending = sort not in _ASCENDING_SORTS descending = sort not in _ASCENDING_SORTS
@@ -497,6 +532,7 @@ class GalleryService:
no_artist: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_from: datetime | None = None,
date_to: datetime | None = None, date_to: datetime | None = None,
include_hidden: bool = False,
) -> list[TimelineBucket]: ) -> list[TimelineBucket]:
eff = _effective_date_col() eff = _effective_date_col()
year_col = func.date_part("year", eff).label("yr") year_col = func.date_part("year", eff).label("yr")
@@ -508,12 +544,15 @@ class GalleryService:
_require_single_filter( _require_single_filter(
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude, tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
) )
hidden = await self._hidden_tag_ids(
include_hidden, tag_ids, tag_or_groups,
)
stmt = _apply_scope( stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id, stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type, artist_id=artist_id, media_type=media_type,
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude, tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
platform=platform, untagged=untagged, no_artist=no_artist, platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to, date_from=date_from, date_to=date_to, hidden_tag_ids=hidden,
) )
stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc()) stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc())
rows = (await self.session.execute(stmt)).all() rows = (await self.session.execute(stmt)).all()
@@ -527,7 +566,7 @@ class GalleryService:
tag_exclude: list[int] | None = None, tag_exclude: list[int] | None = None,
platform: str | None = None, untagged: bool = False, platform: str | None = None, untagged: bool = False,
no_artist: bool = False, date_from: datetime | None = None, no_artist: bool = False, date_from: datetime | None = None,
date_to: datetime | None = None, date_to: datetime | None = None, include_hidden: bool = False,
) -> str | None: ) -> str | None:
"""Returns a cursor that, when passed to scroll() with the same sort, """Returns a cursor that, when passed to scroll() with the same sort,
positions at the first image of the given year-month. None if the positions at the first image of the given year-month. None if the
@@ -544,12 +583,15 @@ class GalleryService:
_require_single_filter( _require_single_filter(
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude, tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
) )
hidden = await self._hidden_tag_ids(
include_hidden, tag_ids, tag_or_groups,
)
stmt = _apply_scope( stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id, stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type, artist_id=artist_id, media_type=media_type,
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude, tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
platform=platform, untagged=untagged, no_artist=no_artist, platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to, date_from=date_from, date_to=date_to, hidden_tag_ids=hidden,
) )
descending = sort != "oldest" descending = sort != "oldest"
if descending: if descending:
@@ -574,6 +616,7 @@ class GalleryService:
platform: str | None = None, platform: str | None = None,
untagged: bool = False, no_artist: bool = False, untagged: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_to: datetime | None = None, date_from: datetime | None = None, date_to: datetime | None = None,
include_hidden: bool = False,
) -> GalleryFacets: ) -> GalleryFacets:
"""Live facet counts scoped to the current filter. Each facet GROUP is """Live facet counts scoped to the current filter. Each facet GROUP is
computed with all OTHER active filters applied but its OWN selection computed with all OTHER active filters applied but its OWN selection
@@ -584,10 +627,14 @@ class GalleryService:
_require_single_filter( _require_single_filter(
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude, tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
) )
hidden = await self._hidden_tag_ids(
include_hidden, tag_ids, tag_or_groups,
)
common = { common = {
"tag_ids": tag_ids, "post_id": post_id, "tag_ids": tag_ids, "post_id": post_id,
"artist_id": artist_id, "media_type": media_type, "artist_id": artist_id, "media_type": media_type,
"tag_or_groups": tag_or_groups, "tag_exclude": tag_exclude, "tag_or_groups": tag_or_groups, "tag_exclude": tag_exclude,
"hidden_tag_ids": hidden,
} }
# total — the full active filter (the headline result count). # total — the full active filter (the headline result count).
+164 -3
View File
@@ -32,13 +32,14 @@ from ...models import (
ImageRecord, ImageRecord,
ImageRegion, ImageRegion,
MLSettings, MLSettings,
PresentationReview,
Tag, Tag,
TagHead, TagHead,
TagKind, TagKind,
TagPositiveConfirmation, TagPositiveConfirmation,
TagSuggestionRejection, TagSuggestionRejection,
) )
from ...models.tag import image_tag from ...models.tag import PRESENTATION_SYSTEM_TAGS, image_tag
from .training_data import ( from .training_data import (
_AUTO_SOURCES, _AUTO_SOURCES,
_auto_apply_point, _auto_apply_point,
@@ -649,8 +650,11 @@ def start_head_auto_apply_run(session: Session, params: dict[str, Any]) -> int:
def _auto_apply_heads(session: Session, embedding_version: str, min_pos: int): def _auto_apply_heads(session: Session, embedding_version: str, min_pos: int):
"""Eligible heads to fire: graduated (auto_apply_threshold set), enough """Eligible CONTENT heads to fire: graduated (auto_apply_threshold set),
support, current embedding. Returns the row list (tag_id/name/weights/...).""" enough support, current embedding, NON-system. System tags never auto-apply
via this path — `wip` never auto-applies at all, and banner/editor screenshot
go through the presentation path at their own flat threshold (#141). Returns
the row list (tag_id/name/weights/...)."""
return session.execute( return session.execute(
select( select(
TagHead.tag_id, Tag.name, TagHead.weights, TagHead.bias, TagHead.tag_id, Tag.name, TagHead.weights, TagHead.bias,
@@ -660,6 +664,7 @@ def _auto_apply_heads(session: Session, embedding_version: str, min_pos: int):
.where(TagHead.embedding_version == embedding_version) .where(TagHead.embedding_version == embedding_version)
.where(TagHead.auto_apply_threshold.is_not(None)) .where(TagHead.auto_apply_threshold.is_not(None))
.where(TagHead.n_pos >= min_pos) .where(TagHead.n_pos >= min_pos)
.where(~Tag.is_system)
).all() ).all()
@@ -743,6 +748,162 @@ def auto_apply_sweep(
return {"n_applied": sum(applied), "concepts": concepts} return {"n_applied": sum(applied), "concepts": concepts}
_PRESENTATION_SOURCE = "presentation_auto"
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)."""
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))
).all()
def _conflict_heads(session: Session, embedding_version: str):
"""ALL content (non-system) heads — the "does this ALSO look like real
content" signal for the presentation conflict guard (#141)."""
return session.execute(
select(TagHead.tag_id, TagHead.weights, TagHead.bias)
.join(Tag, Tag.id == TagHead.tag_id)
.where(TagHead.embedding_version == embedding_version)
.where(~Tag.is_system)
).all()
def _valued_image_ids(session: Session) -> set[int]:
"""Images the operator has shown they value: carrying a HUMAN or CONFIRMED
content (non-system) tag. The presentation sweep never auto-hides these
(guard 1) — you tagged it, so the model doesn't get to bury it (#141)."""
confirmed = exists().where(
TagPositiveConfirmation.image_record_id == image_tag.c.image_record_id,
TagPositiveConfirmation.tag_id == image_tag.c.tag_id,
)
rows = session.execute(
select(image_tag.c.image_record_id)
.join(Tag, Tag.id == image_tag.c.tag_id)
.where(~Tag.is_system)
.where(image_tag.c.source.not_in(_AUTO_SOURCES) | confirmed)
).all()
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}."""
import numpy as np
from sqlalchemy.dialects.postgresql import insert as pg_insert
settings = _settings(session)
if not dry_run and not settings.presentation_auto_apply_enabled:
return {"n_applied": 0, "n_flagged": 0, "concepts": []}
ver = settings.embedder_model_version
pres = _presentation_heads(session, ver)
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)
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)
pres_tag_ids = [r.tag_id for r in pres]
pres_names = [r.name for r in pres]
conf = _conflict_heads(session, ver)
Wc = bc = conf_tag_ids = None
if conf:
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]
valued = _valued_image_ids(session)
# Skip images that already carry, or have rejected, each presentation tag.
skip = {tid: set() for tid in pres_tag_ids}
for tid in pres_tag_ids:
for (iid,) in session.execute(
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
):
skip[tid].add(iid)
for (iid,) in session.execute(
select(TagSuggestionRejection.image_record_id).where(
TagSuggestionRejection.tag_id == tid
)
):
skip[tid].add(iid)
applied = [0] * len(pres)
n_flagged = 0
scanned = 0
all_ids = list(session.execute(
select(ImageRecord.id).where(ImageRecord.siglip_embedding.is_not(None))
).scalars())
for start in range(0, len(all_ids), _AUTO_APPLY_CHUNK):
chunk = all_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
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
probs = 1.0 / (1.0 + np.exp(-(Xn @ Wp.T + bp))) # (N, P)
if Wc is not None:
cprobs = 1.0 / (1.0 + np.exp(-(Xn @ Wc.T + bc))) # (N, C)
max_c = cprobs.max(axis=1)
arg_c = cprobs.argmax(axis=1)
scanned += len(cids)
for p in range(len(pres)):
tid = pres_tag_ids[p]
for idx in np.where(probs[:, p] >= thr)[0]:
iid = cids[int(idx)]
if iid in skip[tid] or iid in valued:
continue
skip[tid].add(iid)
applied[p] += 1
if not dry_run:
session.execute(
pg_insert(image_tag)
.values(
image_record_id=iid, tag_id=tid,
source=_PRESENTATION_SOURCE,
)
.on_conflict_do_nothing()
)
# Guard 2: also looks like content → hide but flag for review.
if Wc is not None and float(max_c[idx]) >= conflict_thr:
n_flagged += 1
if not dry_run:
session.execute(
pg_insert(PresentationReview)
.values(
image_record_id=iid, tag_id=tid,
conflict_tag_id=conf_tag_ids[int(arg_c[idx])],
conflict_score=float(max_c[idx]),
)
.on_conflict_do_nothing()
)
if not dry_run:
session.commit()
concepts = [
{"tag_id": pres_tag_ids[p], "name": pres_names[p],
"applied": applied[p], "scanned": scanned, "threshold": thr}
for p in range(len(pres))
]
return {
"n_applied": sum(applied), "n_flagged": n_flagged, "concepts": concepts,
}
def retract_auto_applied_heads(session: Session) -> int: def retract_auto_applied_heads(session: Session) -> int:
"""Soft auto-apply (milestone 139): re-score every standing source='head_auto' """Soft auto-apply (milestone 139): re-score every standing source='head_auto'
tag against its CURRENT head and REMOVE the ones now BELOW the head's tag against its CURRENT head and REMOVE the ones now BELOW the head's
+1 -1
View File
@@ -29,7 +29,7 @@ from ...models.tag import image_tag
# a CCIP reference) unless the operator confirms them (milestone 139). Keeping # a CCIP reference) unless the operator confirms them (milestone 139). Keeping
# auto-applied predictions out of training is what makes them "soft" — a misfire # 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. # can't reinforce itself, so the retraction sweep can actually drop it.
_AUTO_SOURCES = ("head_auto", "ccip_auto", "ml_auto") _AUTO_SOURCES = ("head_auto", "ccip_auto", "ml_auto", "presentation_auto")
def _hygiene_excluded_ids(session: Session) -> set[int]: def _hygiene_excluded_ids(session: Session) -> set[int]:
+40
View File
@@ -594,6 +594,46 @@ def scheduled_ccip_auto_apply() -> str:
return f"applied={applied}" return f"applied={applied}"
@celery.task(
name="backend.app.tasks.ml.scheduled_presentation_auto_apply",
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
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
result = presentation_auto_apply_sweep(session)
return f"applied={result['n_applied']} 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
days — the operator has acted on them, so they're just history (#141)."""
from datetime import UTC, datetime, timedelta
from sqlalchemy import delete as sa_delete
from ..models import PresentationReview
cutoff = datetime.now(UTC) - timedelta(days=30)
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
res = session.execute(
sa_delete(PresentationReview).where(
PresentationReview.resolved_at.is_not(None),
PresentationReview.resolved_at < cutoff,
)
)
session.commit()
return f"pruned={res.rowcount}"
@celery.task( @celery.task(
name="backend.app.tasks.ml.scheduled_retract_auto_tags", name="backend.app.tasks.ml.scheduled_retract_auto_tags",
soft_time_limit=1800, time_limit=2100, soft_time_limit=1800, time_limit=2100,
@@ -34,6 +34,15 @@
:color="store.filter.no_artist ? 'accent' : undefined" :color="store.filter.no_artist ? 'accent' : undefined"
@click="toggleFlag('no_artist')" @click="toggleFlag('no_artist')"
>No artist<span class="fc-facets__count">{{ facetCount('no_artist') }}</span></v-chip> >No artist<span class="fc-facets__count">{{ facetCount('no_artist') }}</span></v-chip>
<!-- Reveal presentation chrome (banner / editor screenshot) the gallery
hides by default (milestone 141). A browse-mode toggle, not a count. -->
<v-chip
size="small" label
:variant="store.filter.include_hidden ? 'flat' : 'tonal'"
:color="store.filter.include_hidden ? 'accent' : undefined"
title="Show hidden — reveal banners / editor screenshots the gallery sets aside by default"
@click="toggleFlag('include_hidden')"
>Show hidden</v-chip>
</div> </div>
</div> </div>
@@ -160,7 +160,7 @@ let debounce = null
const refineCount = computed(() => { const refineCount = computed(() => {
const f = store.filter const f = store.filter
return (f.platform ? 1 : 0) + (f.untagged ? 1 : 0) + (f.no_artist ? 1 : 0) return (f.platform ? 1 : 0) + (f.untagged ? 1 : 0) + (f.no_artist ? 1 : 0)
+ (f.date_from ? 1 : 0) + (f.date_to ? 1 : 0) + (f.include_hidden ? 1 : 0) + (f.date_from ? 1 : 0) + (f.date_to ? 1 : 0)
}) })
const hasRefineFilters = computed(() => refineCount.value > 0) const hasRefineFilters = computed(() => refineCount.value > 0)
@@ -0,0 +1,151 @@
<template>
<!-- Shown atop the gallery when "Show hidden" is on: auto-hidden chrome that
ALSO looked like real content, 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">
<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
</span>
</div>
<div class="fc-review__cards">
<div
v-for="it in items" :key="keyOf(it)" class="fc-review-card"
>
<img
:src="it.thumbnail_url" :alt="it.tag_name"
class="fc-review-card__thumb" loading="lazy"
>
<div class="fc-review-card__body">
<div
class="fc-review-card__conflict"
:title="`Scored ${Math.round(it.conflict_score * 100)}% on “${it.conflict_name || 'a content tag'}”`"
>
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__acts">
<button
type="button" class="fc-review-btn fc-review-btn--keep"
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'keep')"
>Keep hidden</button>
<button
type="button" class="fc-review-btn fc-review-btn--unhide"
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'unhide')"
>Un-hide</button>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useApi } from '../../composables/useApi.js'
import { useGalleryStore } from '../../stores/gallery.js'
import { toast } from '../../utils/toast.js'
const api = useApi()
const store = useGalleryStore()
const items = ref([])
const busy = ref([])
function keyOf(it) { return `${it.image_id}:${it.tag_id}` }
async function load() {
if (!store.filter.include_hidden) { items.value = []; return }
try {
const body = await api.get('/api/gallery/hidden-review')
items.value = body.items || []
} catch { items.value = [] }
}
async function resolve(it, action) {
const k = keyOf(it)
busy.value = [...busy.value, k]
try {
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' })
}
} catch (e) {
toast({
text: `Could not ${action === 'keep' ? 'keep hidden' : 'un-hide'}: ${e.message}`,
type: 'error',
})
} finally {
busy.value = busy.value.filter((x) => x !== k)
}
}
// Fetch whenever "Show hidden" flips on (immediate covers a mount already-on).
watch(() => store.filter.include_hidden, load, { immediate: true })
</script>
<style scoped>
.fc-review {
margin-bottom: 14px;
padding: 12px;
border: 1px solid rgb(var(--v-theme-warning), 0.4);
background: rgb(var(--v-theme-warning), 0.06);
border-radius: 8px;
}
.fc-review__head {
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
}
.fc-review__title {
font-size: 14px; font-weight: 600;
color: rgb(var(--v-theme-on-surface));
}
.fc-review__cards {
display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px;
}
.fc-review-card {
flex: 0 0 auto; width: 150px;
display: flex; flex-direction: column;
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 6px; overflow: hidden;
background: rgb(var(--v-theme-surface));
}
.fc-review-card__thumb {
width: 100%; height: 96px; object-fit: cover; display: block;
background: rgb(var(--v-theme-surface-light));
}
.fc-review-card__body { padding: 6px 8px; }
.fc-review-card__conflict {
font-size: 11px; color: rgb(var(--v-theme-on-surface));
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fc-review-card__conflict strong { color: rgb(var(--v-theme-warning)); }
.fc-review-card__tag {
font-size: 10px; color: rgb(var(--v-theme-on-surface-variant));
margin: 1px 0 6px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fc-review-card__acts { display: flex; gap: 4px; }
.fc-review-btn {
flex: 1; font-size: 11px; padding: 3px 4px; border-radius: 4px;
cursor: pointer; border: 1px solid transparent;
}
.fc-review-btn:disabled { opacity: 0.5; cursor: default; }
.fc-review-btn--keep {
background: transparent;
color: rgb(var(--v-theme-on-surface-variant));
border-color: rgb(var(--v-theme-on-surface-variant), 0.4);
}
.fc-review-btn--keep:hover:not(:disabled) {
background: rgb(var(--v-theme-on-surface-variant), 0.1);
}
.fc-review-btn--unhide {
background: rgb(var(--v-theme-accent)); color: #fff;
}
.fc-review-btn--unhide:hover:not(:disabled) { opacity: 0.9; }
.fc-review-btn:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px;
}
</style>
+21 -9
View File
@@ -10,7 +10,7 @@
@click:close="$emit('remove', tag.id)" @click:close="$emit('remove', tag.id)"
> >
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon> <v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<v-icon <span class="fc-tag-chip__name">{{ tag.name }}</span><v-icon
v-if="tag.is_system" end size="x-small" class="fc-tag-chip__system" v-if="tag.is_system" end size="x-small" class="fc-tag-chip__system"
title="System tag — tagged items are excluded from training other concepts" title="System tag — tagged items are excluded from training other concepts"
>mdi-shield-outline</v-icon><span >mdi-shield-outline</v-icon><span
@@ -29,7 +29,7 @@
:title="`Keep “${tag.name}” — confirm this auto-tag so it trains the model and won't be retracted`" :title="`Keep “${tag.name}” — confirm this auto-tag so it trains the model and won't be retracted`"
:aria-label="`Confirm ${tag.name}`" :aria-label="`Confirm ${tag.name}`"
@click.stop="$emit('confirm', tag)" @click.stop="$emit('confirm', tag)"
><v-icon size="14">mdi-check</v-icon></button> ><v-icon size="16">mdi-check</v-icon></button>
<!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the <!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the
teleported image modal #711). System tags hide it entirely: rename teleported image modal #711). System tags hide it entirely: rename
is refused server-side (the hygiene machinery keys on the row) and is refused server-side (the hygiene machinery keys on the row) and
@@ -130,7 +130,17 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
</script> </script>
<style scoped> <style scoped>
.fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; } /* A chip must never exceed the rail — a long character+fandom(+AUTO) chip used
to overflow the right edge and clip its close ✕ (operator-flagged 2026-07-06).
The name is the elastic part: it ellipsis-truncates so the ✕, fandom, and AUTO
badge stay reachable. Full name is still available via the chip's hover title. */
.fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; max-width: 100%; min-width: 0; }
.fc-tag-chip__nav { max-width: 100%; min-width: 0; }
.fc-tag-chip__nav :deep(.v-chip__content) { min-width: 0; overflow: hidden; }
.fc-tag-chip__name {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
min-width: 0; flex: 0 1 auto;
}
/* The chip body navigates to the filtered gallery (#5); signal it's clickable. /* The chip body navigates to the filtered gallery (#5); signal it's clickable.
The close ✕ (remove) and the sibling kebab stay as the explicit controls. */ The close ✕ (remove) and the sibling kebab stay as the explicit controls. */
.fc-tag-chip__nav { cursor: pointer; } .fc-tag-chip__nav { cursor: pointer; }
@@ -152,16 +162,18 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
border: 1px solid rgb(var(--v-theme-on-surface-variant), 0.3); border: 1px solid rgb(var(--v-theme-on-surface-variant), 0.3);
padding: 0 4px; border-radius: 999px; padding: 0 4px; border-radius: 999px;
} }
/* Keep/confirm — a success-tinted check next to a provisional auto-tag. */ /* Keep/confirm — the SAME filled green ✓ circle as the suggestion accept button
(.fc-act--yes in SuggestionItem), so "accept this tag" reads identically
wherever it appears (operator-asked 2026-07-06). */
.fc-tag-chip__confirm { .fc-tag-chip__confirm {
flex: 0 0 auto; flex: 0 0 auto;
width: 26px; height: 26px; border-radius: 50%; border: none; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 50%; color: #fff; background: rgb(var(--v-theme-success));
border: none; background: transparent; cursor: pointer; opacity: 0.9; transition: transform 0.1s, opacity 0.1s;
color: rgb(var(--v-theme-success));
} }
.fc-tag-chip__confirm:hover { background: rgb(var(--v-theme-success), 0.14); } .fc-tag-chip__confirm:hover { opacity: 1; transform: scale(1.1); }
.fc-tag-chip__confirm:focus-visible { .fc-tag-chip__confirm:focus-visible {
outline: 2px solid rgb(var(--v-theme-success)); outline-offset: 1px; outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px;
} }
</style> </style>
@@ -159,6 +159,42 @@
</div> </div>
</div> </div>
<!-- Presentation chrome auto-hide (#141) -->
<div class="fc-auto mt-6">
<div class="d-flex align-center mb-1" style="gap: 10px;">
<v-icon size="18" color="accent">mdi-image-off-outline</v-icon>
<span class="fc-section-h">Hide presentation chrome</span>
<v-switch
v-model="presentationEnabled" :loading="settingBusy" hide-details
density="compact" color="success" class="ml-auto"
@update:model-value="onTogglePresentation"
/>
</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
{{ 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.
</p>
<div class="d-flex mb-3" style="gap: 12px;">
<v-text-field
v-model.number="presentationThresholdInput" label="Hide confidence"
type="number" min="0.5" max="0.999" step="0.01" density="compact"
hide-details style="max-width: 200px;" :disabled="settingBusy"
@change="onSavePresentation"
/>
<v-text-field
v-model.number="presentationConflictInput" label="Flag if content ≥"
type="number" min="0" max="1" step="0.05" density="compact"
hide-details style="max-width: 200px;" :disabled="settingBusy"
@change="onSavePresentation"
/>
</div>
</div>
<!-- Performance / tuning --> <!-- Performance / tuning -->
<div v-if="metricsConcepts.length" class="mt-5"> <div v-if="metricsConcepts.length" class="mt-5">
<div class="fc-section-h mb-1">How auto-apply is landing</div> <div class="fc-section-h mb-1">How auto-apply is landing</div>
@@ -218,6 +254,11 @@ const autoStatus = ref(null)
const metricsData = ref(null) const metricsData = ref(null)
let autoTimer = null let autoTimer = null
// --- Presentation chrome auto-hide state (#141) ---
const presentationEnabled = ref(true)
const presentationThresholdInput = ref(0.90)
const presentationConflictInput = ref(0.50)
const autoRunning = computed(() => autoStatus.value?.running_id != null) const autoRunning = computed(() => autoStatus.value?.running_id != null)
const lastSweep = computed(() => const lastSweep = computed(() =>
(autoStatus.value?.runs || []).find(r => r.status !== 'running') || null) (autoStatus.value?.runs || []).find(r => r.status !== 'running') || null)
@@ -248,6 +289,9 @@ onMounted(async () => {
autoEnabled.value = !!s.head_auto_apply_enabled autoEnabled.value = !!s.head_auto_apply_enabled
autoPrecisionInput.value = s.head_auto_apply_precision ?? 0.97 autoPrecisionInput.value = s.head_auto_apply_precision ?? 0.97
autoMinPosInput.value = s.head_auto_apply_min_positives ?? 30 autoMinPosInput.value = s.head_auto_apply_min_positives ?? 30
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
} catch { /* non-fatal */ } } catch { /* non-fatal */ }
await refresh() await refresh()
if (running.value) startPoll() if (running.value) startPoll()
@@ -332,6 +376,32 @@ async function onSaveSettings() {
settingBusy.value = false settingBusy.value = false
} }
} }
async function onTogglePresentation(val) {
settingBusy.value = true
try {
await mlSettings.patchSettings({ presentation_auto_apply_enabled: !!val })
toast({ text: val ? 'Chrome auto-hide on' : 'Chrome auto-hide off', type: 'success' })
} catch (e) {
presentationEnabled.value = !val // revert the switch
toast({ text: `Could not update: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
}
async function onSavePresentation() {
settingBusy.value = true
try {
await mlSettings.patchSettings({
presentation_auto_apply_threshold: Number(presentationThresholdInput.value),
presentation_conflict_threshold: Number(presentationConflictInput.value),
})
} catch (e) {
toast({ text: `Could not save: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
}
function onPreview() { startSweep(true) } function onPreview() { startSweep(true) }
function onApplyNow() { startSweep(false) } function onApplyNow() { startSweep(false) }
async function startSweep(dryRun) { async function startSweep(dryRun) {
+8 -1
View File
@@ -30,6 +30,9 @@ export const useGalleryStore = defineStore('gallery', () => {
tag_or: [], tag_exclude: [], tag_or: [], tag_exclude: [],
// Phase-2 faceted refine params. // Phase-2 faceted refine params.
platform: null, untagged: false, no_artist: false, platform: null, untagged: false, no_artist: false,
// Reveal the presentation chrome (banner / editor screenshot) the gallery
// hides by default (milestone 141).
include_hidden: false,
date_from: null, date_to: null, date_from: null, date_to: null,
// Phase-3 visual similarity: when set, the gallery is in "similar mode" — // Phase-3 visual similarity: when set, the gallery is in "similar mode" —
// ranked by cosine distance to this image, bounded top-N, no cursor. // ranked by cosine distance to this image, bounded top-N, no cursor.
@@ -158,6 +161,7 @@ export const useGalleryStore = defineStore('gallery', () => {
if (filter.value.platform) p.platform = filter.value.platform if (filter.value.platform) p.platform = filter.value.platform
if (filter.value.untagged) p.untagged = '1' if (filter.value.untagged) p.untagged = '1'
if (filter.value.no_artist) p.no_artist = '1' if (filter.value.no_artist) p.no_artist = '1'
if (filter.value.include_hidden) p.include_hidden = '1'
if (filter.value.date_from) p.date_from = filter.value.date_from if (filter.value.date_from) p.date_from = filter.value.date_from
if (filter.value.date_to) p.date_to = filter.value.date_to if (filter.value.date_to) p.date_to = filter.value.date_to
return p return p
@@ -196,6 +200,7 @@ export const useGalleryStore = defineStore('gallery', () => {
platform: q.platform || null, platform: q.platform || null,
untagged: _truthy(q.untagged), untagged: _truthy(q.untagged),
no_artist: _truthy(q.no_artist), no_artist: _truthy(q.no_artist),
include_hidden: _truthy(q.include_hidden),
date_from: _parseDate(q.date_from), date_from: _parseDate(q.date_from),
date_to: _parseDate(q.date_to), date_to: _parseDate(q.date_to),
similar_to: _toId(q.similar_to), similar_to: _toId(q.similar_to),
@@ -284,7 +289,8 @@ export function cloneFilter(f) {
tag_exclude: [...(f.tag_exclude || [])], tag_exclude: [...(f.tag_exclude || [])],
artist_id: f.artist_id, media_type: f.media_type, artist_id: f.artist_id, media_type: f.media_type,
sort: f.sort, platform: f.platform, untagged: f.untagged, sort: f.sort, platform: f.platform, untagged: f.untagged,
no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to, no_artist: f.no_artist, include_hidden: f.include_hidden,
date_from: f.date_from, date_to: f.date_to,
similar_to: f.similar_to, similar_to: f.similar_to,
} }
} }
@@ -301,6 +307,7 @@ export function filterToQuery(f) {
if (f.platform) q.platform = f.platform if (f.platform) q.platform = f.platform
if (f.untagged) q.untagged = '1' if (f.untagged) q.untagged = '1'
if (f.no_artist) q.no_artist = '1' if (f.no_artist) q.no_artist = '1'
if (f.include_hidden) q.include_hidden = '1'
if (f.date_from) q.date_from = f.date_from if (f.date_from) q.date_from = f.date_from
if (f.date_to) q.date_to = f.date_to if (f.date_to) q.date_to = f.date_to
if (f.similar_to) q.similar_to = String(f.similar_to) if (f.similar_to) q.similar_to = String(f.similar_to)
+2
View File
@@ -13,6 +13,7 @@
<div class="fc-gallery-layout__main"> <div class="fc-gallery-layout__main">
<PostInfoHeader /> <PostInfoHeader />
<GalleryFilterBar v-if="store.filter.post_id == null" /> <GalleryFilterBar v-if="store.filter.post_id == null" />
<HiddenReviewStrip />
<EmptyState v-if="store.isEmpty" /> <EmptyState v-if="store.isEmpty" />
<GalleryGrid v-else @open="openImage" /> <GalleryGrid v-else @open="openImage" />
</div> </div>
@@ -33,6 +34,7 @@ import { useGalleryStore } from '../stores/gallery.js'
import { useModalStore } from '../stores/modal.js' import { useModalStore } from '../stores/modal.js'
import GalleryGrid from '../components/gallery/GalleryGrid.vue' import GalleryGrid from '../components/gallery/GalleryGrid.vue'
import GalleryFilterBar from '../components/gallery/GalleryFilterBar.vue' import GalleryFilterBar from '../components/gallery/GalleryFilterBar.vue'
import HiddenReviewStrip from '../components/gallery/HiddenReviewStrip.vue'
import TimelineSidebar from '../components/gallery/TimelineSidebar.vue' import TimelineSidebar from '../components/gallery/TimelineSidebar.vue'
import EmptyState from '../components/gallery/EmptyState.vue' import EmptyState from '../components/gallery/EmptyState.vue'
import PostInfoHeader from '../components/gallery/PostInfoHeader.vue' import PostInfoHeader from '../components/gallery/PostInfoHeader.vue'
+29
View File
@@ -81,6 +81,35 @@ async def test_detector_settings_defaults_patch_and_validation(client):
"/api/ml/settings", json={"detector_max_regions": 0})).status_code == 400 "/api/ml/settings", json={"detector_max_regions": 0})).status_code == 400
@pytest.mark.asyncio
async def test_presentation_settings_defaults_patch_and_validation(client):
# #141: presentation-chrome auto-hide knobs (banner/editor auto-apply +
# the "also looks like content" conflict threshold) are exposed + editable.
body = await (await client.get("/api/ml/settings")).get_json()
assert body["presentation_auto_apply_enabled"] is True
assert body["presentation_auto_apply_threshold"] == 0.90
assert body["presentation_conflict_threshold"] == 0.50
ok = await client.patch("/api/ml/settings", json={
"presentation_auto_apply_enabled": False,
"presentation_auto_apply_threshold": 0.95,
"presentation_conflict_threshold": 0.4,
})
assert ok.status_code == 200
out = await ok.get_json()
assert out["presentation_auto_apply_enabled"] is False
assert out["presentation_auto_apply_threshold"] == 0.95
assert out["presentation_conflict_threshold"] == 0.4
# Auto-apply threshold below the floor + conflict cut above 1 are rejected.
assert (await client.patch(
"/api/ml/settings",
json={"presentation_auto_apply_threshold": 0.4})).status_code == 400
assert (await client.patch(
"/api/ml/settings",
json={"presentation_conflict_threshold": 1.5})).status_code == 400
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_embedder_models_list(client): async def test_embedder_models_list(client):
# #1203: the dropdown reads the supported-model list from the server. # #1203: the dropdown reads the supported-model list from the server.
+42
View File
@@ -1,6 +1,7 @@
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import pytest import pytest
from sqlalchemy import select
from backend.app.models import ImageRecord, Tag, TagKind from backend.app.models import ImageRecord, Tag, TagKind
from backend.app.models.tag import image_tag from backend.app.models.tag import image_tag
@@ -73,6 +74,47 @@ async def test_scroll_returns_newest_first(db):
assert page.images[0].created_at > page.images[-1].created_at assert page.images[0].created_at > page.images[-1].created_at
async def _system_tag(db, name):
return (await db.execute(
select(Tag).where(Tag.is_system.is_(True), Tag.name == name)
)).scalar_one()
@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 = await _system_tag(db, "banner")
wip = await _system_tag(db, "wip")
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.flush()
svc = GalleryService(db)
# Default: the banner image is hidden; the wip image + the plain image 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
# include_hidden surfaces the banner image (the Hidden view).
shown = {i.id for i in (
await svc.scroll(cursor=None, limit=10, include_hidden=True)
).images}
assert imgs[0].id in shown
# Filtering explicitly FOR banner shows it (a view exclusively for a
# presentation tag suppresses the implicit hide).
only_banner = {i.id for i in (
await svc.scroll(cursor=None, limit=10, tag_ids=[banner.id])
).images}
assert only_banner == {imgs[0].id}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scroll_sorts_by_earliest_post_date(db): async def test_scroll_sorts_by_earliest_post_date(db):
"""posted_new/posted_old key off earliest_post_date (original publish across """posted_new/posted_old key off earliest_post_date (original publish across
+89
View File
@@ -0,0 +1,89 @@
"""Hidden-view review endpoints (#141): list flagged auto-hides + keep / un-hide."""
import pytest
from sqlalchemy import select
from backend.app.models import (
ImageRecord,
PresentationReview,
Tag,
TagKind,
TagSuggestionRejection,
)
from backend.app.models.tag import image_tag
pytestmark = pytest.mark.integration
async def _setup_flag(db):
banner = (await db.execute(
select(Tag).where(Tag.is_system.is_(True), Tag.name == "banner")
)).scalar_one()
content = Tag(name="looksreal", kind=TagKind.general)
db.add(content)
img = ImageRecord(
path="/images/rv.jpg", sha256="rv" * 32, size_bytes=1, mime="image/jpeg",
width=1, height=1, origin="imported_filesystem",
integrity_status="unknown",
)
db.add(img)
await db.flush()
await db.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=banner.id, source="presentation_auto"))
db.add(PresentationReview(
image_record_id=img.id, tag_id=banner.id,
conflict_tag_id=content.id, conflict_score=0.8,
))
await db.commit()
return img, banner, content
async def _source(db, image_id, tag_id):
return (await db.execute(
select(image_tag.c.source).where(
image_tag.c.image_record_id == image_id,
image_tag.c.tag_id == tag_id,
)
)).scalar_one_or_none()
@pytest.mark.asyncio
async def test_hidden_review_lists_flag(client, db):
img, banner, content = await _setup_flag(db)
body = await (await client.get("/api/gallery/hidden-review")).get_json()
assert len(body["items"]) == 1
it = body["items"][0]
assert it["image_id"] == img.id
assert it["tag_id"] == banner.id
assert it["conflict_tag_id"] == content.id
assert it["conflict_name"] == "looksreal"
assert it["conflict_score"] == 0.8
assert it["thumbnail_url"]
@pytest.mark.asyncio
async def test_hidden_review_keep_resolves_but_keeps_tag(client, db):
img, banner, _ = await _setup_flag(db)
resp = await client.post(
f"/api/gallery/hidden-review/{img.id}/{banner.id}/keep")
assert resp.status_code == 204
body = await (await client.get("/api/gallery/hidden-review")).get_json()
assert body["items"] == [] # flag resolved
assert await _source(db, img.id, banner.id) == "presentation_auto" # tag stays
@pytest.mark.asyncio
async def test_hidden_review_unhide_removes_tag_and_records_rejection(client, db):
img, banner, _ = await _setup_flag(db)
resp = await client.post(
f"/api/gallery/hidden-review/{img.id}/{banner.id}/unhide")
assert resp.status_code == 204
assert await _source(db, img.id, banner.id) is None # back in the gallery
rej = (await db.execute(
select(TagSuggestionRejection).where(
TagSuggestionRejection.image_record_id == img.id,
TagSuggestionRejection.tag_id == banner.id,
)
)).scalar_one_or_none()
assert rej is not None # head learns it misfired
body = await (await client.get("/api/gallery/hidden-review")).get_json()
assert body["items"] == []
+152
View File
@@ -0,0 +1,152 @@
"""Presentation-chrome auto-hide sweep (#141). numpy-only (no sklearn), so the
apply + guard logic is tested directly via the sync session."""
import pytest
from sqlalchemy import select
from backend.app.models import (
HeadAutoApplyRun,
ImageRecord,
MLSettings,
PresentationReview,
Tag,
TagHead,
TagKind,
)
from backend.app.models.tag import image_tag
from backend.app.services.ml.heads import (
auto_apply_sweep,
presentation_auto_apply_sweep,
)
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):
# weight 3.0 → score sigmoid(3)=0.95 clears the 0.90 presentation floor;
# weight 1.0 → sigmoid(1)=0.73 clears the 0.50 conflict floor.
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 _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_presentation_sweep_hides_chrome(db_sync):
banner = _system_tag(db_sync, "banner")
_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)
assert res["n_applied"] == 1
assert _source(db_sync, img.id, banner.id) == "presentation_auto"
def test_presentation_sweep_hard_skips_valued_image(db_sync):
# An image the operator already tagged with a content tag is never auto-hidden.
banner = _system_tag(db_sync, "banner")
_head(db_sync, banner.id, 0, weight=3.0)
content = Tag(name="mychar", kind=TagKind.character)
db_sync.add(content)
db_sync.flush()
img = _img(db_sync, "b" * 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 = presentation_auto_apply_sweep(db_sync)
assert res["n_applied"] == 0
assert _source(db_sync, img.id, banner.id) is None
def test_presentation_sweep_flags_conflict(db_sync):
# Matches banner AND scores high on a content head → hidden but flagged with
# that content tag as the conflict.
banner = _system_tag(db_sync, "banner")
_head(db_sync, banner.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) # content head also fires
img = _img(db_sync, "c" * 64, _emb(0))
db_sync.commit()
res = presentation_auto_apply_sweep(db_sync)
assert res["n_applied"] == 1
assert res["n_flagged"] == 1
assert _source(db_sync, img.id, banner.id) == "presentation_auto"
flag = db_sync.execute(
select(PresentationReview).where(
PresentationReview.image_record_id == img.id,
PresentationReview.tag_id == banner.id,
)
).scalar_one()
assert flag.conflict_tag_id == content.id
assert flag.conflict_score >= 0.5
def test_presentation_sweep_disabled_is_noop(db_sync):
s = db_sync.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one()
s.presentation_auto_apply_enabled = False
banner = _system_tag(db_sync, "banner")
_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)
assert res["n_applied"] == 0
assert _source(db_sync, img.id, banner.id) is None
def test_presentation_sweep_ignores_wip(db_sync):
# wip is a system tag but NOT presentation chrome → never auto-applied.
wip = _system_tag(db_sync, "wip")
_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)
assert res["n_applied"] == 0
assert _source(db_sync, img.id, wip.id) is None
def test_content_sweep_never_fires_system_tags(db_sync):
# A graduated banner (system) head must NOT auto-apply via the content path.
banner = _system_tag(db_sync, "banner")
_head(db_sync, banner.id, 0, weight=3.0)
img = _img(db_sync, "f" * 64, _emb(0))
run = HeadAutoApplyRun(dry_run=False, params={}, status="running")
db_sync.add(run)
db_sync.flush()
db_sync.commit()
auto_apply_sweep(db_sync, run, dry_run=False)
assert _source(db_sync, img.id, banner.id) is None
+98
View File
@@ -0,0 +1,98 @@
"""Scheduled presentation tasks (#141): the daily auto-hide sweep wrapper +
resolved-flag retention. The task opens its own session via
_sync_session_factory, monkeypatched here to run against the test's db_sync."""
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import select
from backend.app.models import (
ImageRecord,
MLSettings,
PresentationReview,
Tag,
TagHead,
)
from backend.app.tasks.ml import (
prune_presentation_reviews,
scheduled_presentation_auto_apply,
)
pytestmark = pytest.mark.integration
class _Ctx:
def __init__(self, s):
self.s = s
def __enter__(self):
return self.s
def __exit__(self, *a):
return False
def _sf(db_sync):
class _SM:
def __call__(self):
return _Ctx(db_sync)
return _SM()
def _img(db, sha, emb=None):
img = ImageRecord(
path=f"/images/{sha}.jpg", sha256=sha * 32, 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 test_scheduled_presentation_sweep_hides_chrome(db_sync, monkeypatch):
monkeypatch.setattr(
"backend.app.tasks.ml._sync_session_factory", lambda: _sf(db_sync)
)
banner = db_sync.execute(
select(Tag).where(Tag.is_system.is_(True), Tag.name == "banner")
).scalar_one()
s = db_sync.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one()
w = [0.0] * 1152
w[0] = 3.0
db_sync.add(TagHead(
tag_id=banner.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,
))
emb = [0.0] * 1152
emb[0] = 3.0
_img(db_sync, "sc", emb)
db_sync.commit()
assert "applied=1" in scheduled_presentation_auto_apply()
def test_prune_presentation_reviews_drops_old_resolved(db_sync, monkeypatch):
monkeypatch.setattr(
"backend.app.tasks.ml._sync_session_factory", lambda: _sf(db_sync)
)
banner = db_sync.execute(
select(Tag).where(Tag.is_system.is_(True), Tag.name == "banner")
).scalar_one()
a, b, c = _img(db_sync, "p1"), _img(db_sync, "p2"), _img(db_sync, "p3")
old = datetime.now(UTC) - timedelta(days=40)
recent = datetime.now(UTC) - timedelta(days=1)
# old resolved → pruned; recent resolved → kept; unresolved → kept.
db_sync.add(PresentationReview(
image_record_id=a.id, tag_id=banner.id, conflict_score=0.7, resolved_at=old))
db_sync.add(PresentationReview(
image_record_id=b.id, tag_id=banner.id, conflict_score=0.7, resolved_at=recent))
db_sync.add(PresentationReview(
image_record_id=c.id, tag_id=banner.id, conflict_score=0.7))
db_sync.commit()
assert prune_presentation_reviews() == "pruned=1"
remaining = db_sync.execute(
select(PresentationReview.image_record_id)
).scalars().all()
assert set(remaining) == {b.id, c.id}