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:
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user