diff --git a/alembic/versions/0082_presentation_auto_hide.py b/alembic/versions/0082_presentation_auto_hide.py new file mode 100644 index 0000000..8dc1f3c --- /dev/null +++ b/alembic/versions/0082_presentation_auto_hide.py @@ -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") diff --git a/backend/app/api/ml_admin.py b/backend/app/api/ml_admin.py index c146b70..52e26fd 100644 --- a/backend/app/api/ml_admin.py +++ b/backend/app/api/ml_admin.py @@ -39,6 +39,9 @@ _EDITABLE = ( "ccip_match_threshold", "ccip_auto_apply_enabled", "ccip_auto_apply_threshold", + "presentation_auto_apply_enabled", + "presentation_auto_apply_threshold", + "presentation_conflict_threshold", "embedder_model_name", "embedder_model_version", *_DETECTOR_FIELDS, @@ -96,6 +99,9 @@ async def get_settings(): "ccip_match_threshold": s.ccip_match_threshold, "ccip_auto_apply_enabled": s.ccip_auto_apply_enabled, "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, **{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" 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" + # 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 # different embedding space — the operator must re-embed + retrain after. for key in ("embedder_model_name", "embedder_model_version"): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0da2555..f06d74d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -28,6 +28,7 @@ from .pixiv_failed_media import PixivFailedMedia from .pixiv_seen_media import PixivSeenMedia from .post import Post from .post_attachment import PostAttachment +from .presentation_review import PresentationReview from .series_chapter import SeriesChapter from .series_page import SeriesPage from .series_suggestion import SeriesSuggestion @@ -57,6 +58,7 @@ __all__ = [ "SubscribeStarSeenMedia", "Post", "PostAttachment", + "PresentationReview", "SeriesChapter", "SeriesPage", "SeriesSuggestion", diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py index 80f81f9..0d38e91 100644 --- a/backend/app/models/ml_settings.py +++ b/backend/app/models/ml_settings.py @@ -84,6 +84,22 @@ class MLSettings(Base): # character matches auto-tag. 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); # 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 new file mode 100644 index 0000000..d6f91c4 --- /dev/null +++ b/backend/app/models/presentation_review.py @@ -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 ") +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 + ) diff --git a/tests/test_api_ml_admin.py b/tests/test_api_ml_admin.py index 33faf09..df9141d 100644 --- a/tests/test_api_ml_admin.py +++ b/tests/test_api_ml_admin.py @@ -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 +@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 async def test_embedder_models_list(client): # #1203: the dropdown reads the supported-model list from the server.