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_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"):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
"/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.
|
||||||
|
|||||||
Reference in New Issue
Block a user