feat(ml): presentation auto-hide settings + review table (#141 step 3)
MLSettings gains presentation_auto_apply_enabled / _threshold (default 0.90) + presentation_conflict_threshold (default 0.50): banner/editor auto-hide with a FLAT threshold (decoupled from content-head graduation), plus the "also looks like content" conflict cut. New presentation_review table (image, presentation tag, conflict tag + score, created/resolved_at) records auto-hides flagged for review. Migration 0082 (columns + table), ml_admin API (editable + get_settings + _validate bounds), settings roundtrip/bounds test. The sweep that reads these knobs + the Settings UI land in step 4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
@@ -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")
|
||||
@@ -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"):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user