Compare commits
21
Commits
31d400ab0a
...
ext-1.0.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89c83ee5de | ||
|
|
69b5637bd6 | ||
|
|
d3192f1843 | ||
|
|
51749e05db | ||
|
|
5a5694f200 | ||
|
|
50d6c42207 | ||
|
|
bb1a938cc0 | ||
|
|
67c7ca8603 | ||
|
|
fc0293029d | ||
|
|
eed42a260a | ||
|
|
61b14e8f65 | ||
|
|
7d1c701b67 | ||
|
|
447bf73519 | ||
|
|
b638382cd5 | ||
|
|
6104452d2e | ||
|
|
b59828635e | ||
|
|
17903068b4 | ||
|
|
fac5ae6ce5 | ||
|
|
af0d39ed52 | ||
|
|
d9a14e890d | ||
|
|
ad2a5fc5fe |
@@ -0,0 +1,61 @@
|
||||
"""process auto-apply settings + review mode (#1464) — system-tag refactor
|
||||
|
||||
The system-tag behavior refactor gives `wip` / `editor screenshot` (the PROCESS
|
||||
group) their own provisional auto-apply, parallel to the presentation (chrome)
|
||||
sweep. MLSettings gains three knobs: enabled (OFF by default — a new whole-library
|
||||
auto-tagger is opt-in), the flat apply threshold, and the ring-loud conflict
|
||||
threshold. presentation_review gains a `mode` column so one review surface serves
|
||||
both chrome and process flags (existing rows backfill 'chrome'). server_defaults
|
||||
so the existing rows fill cleanly.
|
||||
|
||||
Revision ID: 0086
|
||||
Revises: 0085
|
||||
Create Date: 2026-07-13
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0086"
|
||||
down_revision: Union[str, None] = "0085"
|
||||
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(
|
||||
"process_auto_apply_enabled", sa.Boolean(), nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"process_auto_apply_threshold", sa.Float(), nullable=False,
|
||||
server_default="0.90",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"process_conflict_threshold", sa.Float(), nullable=False,
|
||||
server_default="0.50",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"presentation_review",
|
||||
sa.Column(
|
||||
"mode", sa.String(16), nullable=False,
|
||||
server_default="chrome",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("presentation_review", "mode")
|
||||
op.drop_column("ml_settings", "process_conflict_threshold")
|
||||
op.drop_column("ml_settings", "process_auto_apply_threshold")
|
||||
op.drop_column("ml_settings", "process_auto_apply_enabled")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""soft WIP title tier toggle (#1474) — ImportSettings.wip_soft_title_tagging_enabled
|
||||
|
||||
The soft tier also tags sketch/doodle/scribble titles, but with a provisional source
|
||||
that never trains the head. OFF by default (a lower-precision tier is opt-in).
|
||||
server_default so the existing singleton row (id=1) fills cleanly.
|
||||
|
||||
Revision ID: 0087
|
||||
Revises: 0086
|
||||
Create Date: 2026-07-13
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0087"
|
||||
down_revision: Union[str, None] = "0086"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"wip_soft_title_tagging_enabled", sa.Boolean(), nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("import_settings", "wip_soft_title_tagging_enabled")
|
||||
@@ -148,6 +148,17 @@ async def similar():
|
||||
# Explore passes exclude_wip=1 to also drop work-in-progress from the
|
||||
# rabbit-hole; the gallery's own "similar" button omits it (keeps wip, #1274).
|
||||
exclude_wip = request.args.get("exclude_wip") in ("1", "true", "True")
|
||||
# Explore reach (#1476): 0 = nearest (gallery default), →1 reaches into farther
|
||||
# distance bands so the walk can escape a dense cluster. exclude_ids = the
|
||||
# breadcrumb, so already-walked images aren't re-served as neighbours.
|
||||
try:
|
||||
reach = max(0.0, min(1.0, float(request.args.get("reach", "0"))))
|
||||
except ValueError:
|
||||
reach = 0.0
|
||||
exclude_ids = [
|
||||
int(x) for x in request.args.get("exclude_ids", "").split(",")
|
||||
if x.strip().isdigit()
|
||||
] or None
|
||||
# post_id is the exclusive post-detail view — not a similarity scope.
|
||||
# include_hidden is a gallery-browse flag; similar() has its OWN presentation
|
||||
# exclusion (a similarity-quality concern, #1274), so drop it here (#141).
|
||||
@@ -158,7 +169,8 @@ async def similar():
|
||||
svc = GalleryService(session)
|
||||
try:
|
||||
images = await svc.similar(
|
||||
image_id=similar_to, limit=limit, exclude_wip=exclude_wip, **scope)
|
||||
image_id=similar_to, limit=limit, exclude_wip=exclude_wip,
|
||||
reach=reach, exclude_ids=exclude_ids, **scope)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if images is None:
|
||||
@@ -236,8 +248,10 @@ async def jump():
|
||||
# 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."""
|
||||
"""Unresolved system-tag auto-apply review flags (chrome + process, #1464),
|
||||
most-concerning first (highest content score) — for the review strip. `mode`
|
||||
tells the client whether the flagged tag hid the image ('chrome') or left it
|
||||
visible ('process'), which decides the resolve labels (un-hide vs remove-tag)."""
|
||||
ptag = aliased(Tag)
|
||||
ctag = aliased(Tag)
|
||||
async with get_session() as session:
|
||||
@@ -247,6 +261,7 @@ async def hidden_review():
|
||||
PresentationReview.tag_id,
|
||||
PresentationReview.conflict_tag_id,
|
||||
PresentationReview.conflict_score,
|
||||
PresentationReview.mode,
|
||||
ImageRecord.path, ImageRecord.thumbnail_path,
|
||||
ImageRecord.sha256, ImageRecord.mime,
|
||||
ptag.name.label("tag_name"),
|
||||
@@ -266,6 +281,7 @@ async def hidden_review():
|
||||
"conflict_tag_id": r.conflict_tag_id,
|
||||
"conflict_name": r.conflict_name,
|
||||
"conflict_score": r.conflict_score,
|
||||
"mode": r.mode,
|
||||
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
|
||||
"image_url": image_url(r.path),
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ _EDITABLE = (
|
||||
"presentation_auto_apply_enabled",
|
||||
"presentation_auto_apply_threshold",
|
||||
"presentation_conflict_threshold",
|
||||
"process_auto_apply_enabled",
|
||||
"process_auto_apply_threshold",
|
||||
"process_conflict_threshold",
|
||||
"embedder_model_name",
|
||||
"embedder_model_version",
|
||||
*_DETECTOR_FIELDS,
|
||||
@@ -102,6 +105,9 @@ async def get_settings():
|
||||
"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,
|
||||
"process_auto_apply_enabled": s.process_auto_apply_enabled,
|
||||
"process_auto_apply_threshold": s.process_auto_apply_threshold,
|
||||
"process_conflict_threshold": s.process_conflict_threshold,
|
||||
"embedder_model_name": s.embedder_model_name,
|
||||
**{f: getattr(s, f) for f in _DETECTOR_FIELDS},
|
||||
}
|
||||
@@ -162,6 +168,12 @@ def _validate(p: dict) -> str | None:
|
||||
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"
|
||||
# Process auto-apply (#1464). wip/editor stay VISIBLE so a false apply is
|
||||
# low-harm (excludes-from-training + a review flag), but keep the same bar.
|
||||
if not (0.5 <= float(p["process_auto_apply_threshold"]) <= 0.999):
|
||||
return "process_auto_apply_threshold must be between 0.5 and 0.999"
|
||||
if not (0.0 <= float(p["process_conflict_threshold"]) <= 1.0):
|
||||
return "process_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"):
|
||||
|
||||
@@ -49,6 +49,7 @@ _EDITABLE_FIELDS = (
|
||||
"translation_target_lang",
|
||||
"translation_min_confidence",
|
||||
"wip_title_tagging_enabled",
|
||||
"wip_soft_title_tagging_enabled",
|
||||
)
|
||||
|
||||
# Per-host external-download toggles — all plain booleans, validated uniformly.
|
||||
@@ -91,6 +92,7 @@ async def get_import_settings():
|
||||
"translation_target_lang": row.translation_target_lang,
|
||||
"translation_min_confidence": row.translation_min_confidence,
|
||||
"wip_title_tagging_enabled": row.wip_title_tagging_enabled,
|
||||
"wip_soft_title_tagging_enabled": row.wip_soft_title_tagging_enabled,
|
||||
})
|
||||
|
||||
|
||||
@@ -179,6 +181,12 @@ async def update_import_settings():
|
||||
return jsonify(
|
||||
{"error": "wip_title_tagging_enabled must be a boolean"}
|
||||
), 400
|
||||
if "wip_soft_title_tagging_enabled" in body and not isinstance(
|
||||
body["wip_soft_title_tagging_enabled"], bool
|
||||
):
|
||||
return jsonify(
|
||||
{"error": "wip_soft_title_tagging_enabled must be a boolean"}
|
||||
), 400
|
||||
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
|
||||
@@ -171,9 +171,19 @@ def make_celery() -> Celery:
|
||||
},
|
||||
"presentation-auto-apply-daily": {
|
||||
"task": "backend.app.tasks.ml.scheduled_presentation_auto_apply",
|
||||
"schedule": 86400.0, # auto-hide banner/editor chrome (#141);
|
||||
"schedule": 86400.0, # auto-hide banner chrome (#141);
|
||||
# no-op unless presentation_auto_apply_enabled
|
||||
},
|
||||
"process-auto-apply-daily": {
|
||||
"task": "backend.app.tasks.ml.scheduled_process_auto_apply",
|
||||
"schedule": 86400.0, # auto-tag wip/editor process art (#1464);
|
||||
# no-op unless process_auto_apply_enabled (opt-in)
|
||||
},
|
||||
"soft-wip-conflict-audit-daily": {
|
||||
"task": "backend.app.tasks.ml.scheduled_soft_wip_conflict_audit",
|
||||
"schedule": 86400.0, # flag ring-loud soft-WIP (sketch/doodle) tags
|
||||
# for review (#1474); no-op with no content heads
|
||||
},
|
||||
"prune-presentation-reviews-daily": {
|
||||
"task": "backend.app.tasks.ml.prune_presentation_reviews",
|
||||
"schedule": 86400.0, # retention: drop resolved review flags >30d
|
||||
|
||||
@@ -126,6 +126,13 @@ class ImportSettings(Base):
|
||||
wip_title_tagging_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default="true",
|
||||
)
|
||||
# Soft WIP title tier (#1474): also tag sketch/doodle/scribble titles, but with
|
||||
# a PROVISIONAL source (`wip_title_soft`) that never trains the head, since these
|
||||
# are lower-precision (a finished "sketch" isn't WIP). OFF by default — a lower-
|
||||
# precision tier is opt-in (the ring-loud audit surfaces false positives).
|
||||
wip_soft_title_tagging_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="false",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def load(cls, session) -> ImportSettings:
|
||||
|
||||
@@ -85,12 +85,14 @@ class MLSettings(Base):
|
||||
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.
|
||||
# `banner` (chrome — clusters on UI, not content) auto-applies on the sweep
|
||||
# with its OWN flat threshold (decoupled from content-head graduation) and is
|
||||
# HIDDEN from the gallery. Hiding is consequential so it runs HIGH. 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, mode='chrome') instead of buried silently. ON by default
|
||||
# (opt-out); every auto-tag is reversible. NOTE (#1464): `wip` + `editor
|
||||
# screenshot` are no longer chrome — they went to the PROCESS path below.
|
||||
presentation_auto_apply_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
@@ -100,6 +102,26 @@ class MLSettings(Base):
|
||||
presentation_conflict_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.50
|
||||
)
|
||||
# -- Process auto-apply (#1464) ----------------------------------------
|
||||
# `wip` / `editor screenshot` are PROCESS art — unfinished pieces + program
|
||||
# screenshots that must stay OUT of head/CCIP training but, unlike chrome,
|
||||
# remain VISIBLE in the gallery (operator 2026-07-12). They auto-apply on the
|
||||
# sweep with their OWN flat threshold and a PROVISIONAL source (`process_auto`,
|
||||
# in training_data._AUTO_SOURCES) so the head NEVER trains on its own output —
|
||||
# it learns only from title (`wip_title`) + manual labels, which breaks the
|
||||
# runaway loop. When a process tag would be applied but the image ALSO scores
|
||||
# >= process_conflict_threshold on a content head, it's flagged for review
|
||||
# (PresentationReview, mode='process') rather than silently marked. OFF by
|
||||
# default — a new whole-library auto-tagger is opt-in; every auto-tag reversible.
|
||||
process_auto_apply_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
process_auto_apply_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.90
|
||||
)
|
||||
process_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(
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""PresentationReview — an auto-hidden presentation tag that ALSO looked like
|
||||
real content, flagged for operator review (milestone 141).
|
||||
"""PresentationReview — a system-tag the auto-apply sweep applied that ALSO looked
|
||||
like real content, flagged for operator review (milestone 141 + #1464).
|
||||
|
||||
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.
|
||||
When a sweep applies a system tag but the image ALSO scores highly on a content
|
||||
head, it still applies the tag but records this row so a review strip can surface
|
||||
it ("⚠ also looks like <conflict tag>"). Two modes (#1464): 'chrome' (banner —
|
||||
image is HIDDEN, review is keep-hidden / un-hide) and 'process' (wip / editor
|
||||
screenshot — image stays VISIBLE, review is confirm / remove-tag). Resolved rows
|
||||
are pruned by retention.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, func
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -31,6 +33,12 @@ class PresentationReview(Base):
|
||||
ForeignKey("tag.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
conflict_score: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
# Which sweep flagged this (#1464): 'chrome' (banner, hidden) or 'process'
|
||||
# (wip / editor screenshot, shown). Drives which review strip surfaces it and
|
||||
# what "resolve" means (un-hide vs remove-tag). Existing rows backfill 'chrome'.
|
||||
mode: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="chrome", server_default="chrome"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -43,14 +43,19 @@ class TagKind(StrEnum):
|
||||
# to keep historic tag rows queryable.
|
||||
|
||||
|
||||
# The seeded system tags (migration 0075). PRESENTATION tags additionally
|
||||
# hide from whole-image similarity results — they cluster on UI chrome, not
|
||||
# content. `wip` is real art: only the training pipelines exclude it.
|
||||
# The seeded system tags (migration 0075). Two behavior groups (#1464):
|
||||
# CHROME (banner): clusters on UI chrome, not content → HIDDEN from the default
|
||||
# gallery + from similarity; auto-applied via the sweep's chrome mode.
|
||||
# PROCESS (wip, editor screenshot): real-but-unfinished art / program screenshots
|
||||
# → SHOWN in the gallery (operator 2026-07-12), but excluded from the Explore
|
||||
# rabbit-hole; auto-applied via the sweep's process mode (provisional source,
|
||||
# ring-loud review guard).
|
||||
# ALL three are excluded from OTHER concepts' head/CCIP training (training-hygiene,
|
||||
# keyed on is_system); a system tag's OWN head trains on them — that's what makes
|
||||
# auto-flagging work.
|
||||
SYSTEM_TAG_NAMES = ("wip", "banner", "editor screenshot")
|
||||
PRESENTATION_SYSTEM_TAGS = ("banner", "editor screenshot")
|
||||
# `wip` marks real-but-unfinished art. It's kept in the gallery's own "similar"
|
||||
# results (#1274), but the Explore rabbit-hole opts to hide it (exclude_wip) so a
|
||||
# browse doesn't keep surfacing work-in-progress (operator, 2026-07-08).
|
||||
CHROME_SYSTEM_TAGS = ("banner",)
|
||||
PROCESS_SYSTEM_TAGS = ("wip", "editor screenshot")
|
||||
WIP_SYSTEM_TAG = "wip"
|
||||
|
||||
image_tag = Table(
|
||||
|
||||
@@ -35,9 +35,14 @@ class InvalidUrlError(Exception):
|
||||
# reviewers catch drift.
|
||||
_PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||
("patreon", re.compile(
|
||||
# Three creator URL shapes — bare (patreon.com/Atole), `c/`, and `cw/`
|
||||
# (the "creator workspace" URL served once subscribed, see
|
||||
# patreon_resolver._VANITY_RE). A trailing sub-path is allowed so a
|
||||
# creator's inner page still derives the slug. Nav pages stay excluded.
|
||||
r"^https?://(?:www\.)?patreon\.com/"
|
||||
r"(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c/)"
|
||||
r"(?P<slug>[^/?#]+)/?$",
|
||||
r"(?:cw/|c/)?"
|
||||
r"(?!(?:home|search|messages|notifications|library|settings|posts)(?:[/?#]|$))"
|
||||
r"(?P<slug>[^/?#]+)",
|
||||
re.IGNORECASE,
|
||||
)),
|
||||
("subscribestar", re.compile(
|
||||
|
||||
@@ -31,7 +31,7 @@ from ..models import (
|
||||
Tag,
|
||||
TagPositiveConfirmation,
|
||||
)
|
||||
from ..models.tag import PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG, image_tag
|
||||
from ..models.tag import CHROME_SYSTEM_TAGS, PROCESS_SYSTEM_TAGS, image_tag
|
||||
from .pagination import decode_cursor, encode_cursor
|
||||
from .tag_query import (
|
||||
fandom_join_alias,
|
||||
@@ -396,6 +396,25 @@ def _diversify_similar(src, rows, limit, *, dup_threshold=8, lam=0.40):
|
||||
return [kept[i] for i in order]
|
||||
|
||||
|
||||
def _reach_sample(rows, limit, reach):
|
||||
"""From a distance-sorted candidate pool (nearest first), pick a spread of ranks
|
||||
that MIXES near (tag the current cluster) and mid-far (escape it) BEFORE dedup +
|
||||
MMR — the Explore "reach" dial (#1476).
|
||||
|
||||
reach in (0, 1]: the sampled span grows outward from the anchor (0.25→1.0 of the
|
||||
pool), evenly strided from rank 0 so the nearest are still represented. In a
|
||||
dense signature the nearest ranks are near-identical, so reaching farther is the
|
||||
only way to hand MMR genuinely different content — MMR alone can't escape a pool
|
||||
that's already all-near. reach<=0 or a small pool passes through unchanged."""
|
||||
n = len(rows)
|
||||
want = max(limit * 8, 100)
|
||||
if reach <= 0 or n <= want:
|
||||
return rows
|
||||
span = int(min(1.0, 0.25 + 0.75 * reach) * n)
|
||||
idx = sorted({min(int(i * span / want), n - 1) for i in range(want)})
|
||||
return [rows[i] for i in idx]
|
||||
|
||||
|
||||
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
|
||||
"""Map image_id -> {"name","slug"} via the canonical
|
||||
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
||||
@@ -419,16 +438,17 @@ class GalleryService:
|
||||
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)"""
|
||||
"""Chrome (banner) 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 chrome tag (they clearly want to see it), or when
|
||||
no chrome tags exist. (milestone 141; #1464: editor screenshot is now PROCESS
|
||||
— shown — so only `banner` hides here.)"""
|
||||
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),
|
||||
Tag.name.in_(CHROME_SYSTEM_TAGS),
|
||||
)
|
||||
)
|
||||
pres = [r[0] for r in rows]
|
||||
@@ -716,6 +736,7 @@ class GalleryService:
|
||||
untagged: bool = False, no_artist: bool = False,
|
||||
date_from: datetime | None = None, date_to: datetime | None = None,
|
||||
exclude_wip: bool = False,
|
||||
reach: float = 0.0, exclude_ids: list[int] | None = None,
|
||||
) -> list[GalleryImage] | None:
|
||||
"""Visual "more like this": images near `image_id`'s SigLIP embedding
|
||||
(pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result
|
||||
@@ -744,20 +765,27 @@ class GalleryService:
|
||||
# wide pool there's nothing but the near-dupes to choose from. Widened
|
||||
# (5×→8×, cap 200→400) so the stronger MMR has genuinely distinct
|
||||
# neighbourhoods to reach into for more variance (operator, 2026-07-01).
|
||||
pool_n = min(400, max(limit * 8, 100))
|
||||
# Explore's reach>0 (#1476) widens it a LOT more: in a dense signature the
|
||||
# nearest few hundred are all near-identical, so far-enough candidates only
|
||||
# exist deeper in the ranked pool. _reach_sample then strides across them.
|
||||
if reach > 0:
|
||||
pool_n = min(1000, max(limit * 25, 100))
|
||||
else:
|
||||
pool_n = min(400, max(limit * 8, 100))
|
||||
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
|
||||
eff = _effective_date_col()
|
||||
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||
stmt = _outer_join_primary_post(stmt)
|
||||
# Presentation images (banner / editor-screenshot system tags, #128)
|
||||
# cluster on UI chrome rather than content, so near any one of them
|
||||
# they'd fill the grid. Excluded from CANDIDATES only — the anchor
|
||||
# itself may be a banner. `wip` stays surfaced here by default (real art;
|
||||
# only the training pipelines exclude it), but the Explore rabbit-hole
|
||||
# passes exclude_wip to also drop work-in-progress (operator, 2026-07-08).
|
||||
excluded_system_tags = PRESENTATION_SYSTEM_TAGS
|
||||
# Chrome (banner, #128) clusters on UI rather than content, so near any one
|
||||
# of them they'd fill the grid → excluded from CANDIDATES always (the anchor
|
||||
# itself may be a banner). PROCESS art (wip / editor screenshot) stays
|
||||
# surfaced here by default (real content; only the training pipelines exclude
|
||||
# it), but the Explore rabbit-hole passes exclude_wip to also drop the whole
|
||||
# process group so a browse doesn't keep surfacing work-in-progress
|
||||
# (operator, 2026-07-08; #1464 — editor now rides with wip here).
|
||||
excluded_system_tags = CHROME_SYSTEM_TAGS
|
||||
if exclude_wip:
|
||||
excluded_system_tags = (*PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG)
|
||||
excluded_system_tags = (*CHROME_SYSTEM_TAGS, *PROCESS_SYSTEM_TAGS)
|
||||
presentation = (
|
||||
select(image_tag.c.image_record_id)
|
||||
.join(Tag, Tag.id == image_tag.c.tag_id)
|
||||
@@ -771,6 +799,10 @@ class GalleryService:
|
||||
ImageRecord.id != image_id,
|
||||
ImageRecord.id.not_in(presentation),
|
||||
)
|
||||
# Anti-revisit (#1476): the Explore walk passes its breadcrumb so already-
|
||||
# walked images aren't re-served as neighbours — → can't loop you back in.
|
||||
if exclude_ids:
|
||||
stmt = stmt.where(ImageRecord.id.not_in(exclude_ids))
|
||||
stmt = _apply_scope(
|
||||
stmt, tag_ids=tag_ids, post_id=None,
|
||||
artist_id=artist_id, media_type=media_type,
|
||||
@@ -780,6 +812,10 @@ class GalleryService:
|
||||
)
|
||||
stmt = stmt.order_by(distance.asc()).limit(pool_n)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
# Explore reach: stride across an outward-growing distance span so the pool
|
||||
# handed to MMR spans near→mid-far, not just the tight cluster (#1476).
|
||||
if reach > 0:
|
||||
rows = _reach_sample(rows, limit, reach)
|
||||
rows = _diversify_similar(src, rows, limit)
|
||||
artists = await _artists_for(self.session, [r[0].id for r in rows])
|
||||
return _gallery_images(rows, artists)
|
||||
|
||||
@@ -47,7 +47,14 @@ from .attachment_store import AttachmentStore
|
||||
from .audits import single_color
|
||||
from .link_extract import extract_external_links
|
||||
from .thumbnailer import Thumbnailer
|
||||
from .wip_title import apply_wip_image_tags, matches_wip_title, resolve_wip_tag_id
|
||||
from .wip_title import (
|
||||
WIP_TITLE_SOFT_SOURCE,
|
||||
WIP_TITLE_SOURCE,
|
||||
apply_wip_image_tags,
|
||||
matches_soft_wip_title,
|
||||
matches_wip_title,
|
||||
resolve_wip_tag_id,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -999,7 +1006,9 @@ class Importer:
|
||||
removal sticks. The existing catalogue is covered separately by the
|
||||
operator-triggered backfill sweep. Gated by the settings toggle, and
|
||||
best-effort: any failure is logged, never allowed to fail the import."""
|
||||
if not self.settings.wip_title_tagging_enabled:
|
||||
hard_on = self.settings.wip_title_tagging_enabled
|
||||
soft_on = self.settings.wip_soft_title_tagging_enabled
|
||||
if not (hard_on or soft_on):
|
||||
return
|
||||
if record.primary_post_id is None:
|
||||
return
|
||||
@@ -1007,13 +1016,22 @@ class Importer:
|
||||
title = self.session.execute(
|
||||
select(Post.post_title).where(Post.id == record.primary_post_id)
|
||||
).scalar_one_or_none()
|
||||
if not matches_wip_title(title):
|
||||
# HARD tier ("WIP"/"work in progress") wins — higher precision, and it
|
||||
# trains the head; SOFT (sketch/doodle, #1474) is the provisional fallback
|
||||
# that never trains (source wip_title_soft).
|
||||
if hard_on and matches_wip_title(title):
|
||||
source = WIP_TITLE_SOURCE
|
||||
elif soft_on and matches_soft_wip_title(title):
|
||||
source = WIP_TITLE_SOFT_SOURCE
|
||||
else:
|
||||
return
|
||||
if self._wip_tag_id is _UNSET:
|
||||
self._wip_tag_id = resolve_wip_tag_id(self.session)
|
||||
if self._wip_tag_id is None:
|
||||
return
|
||||
apply_wip_image_tags(self.session, [record.id], self._wip_tag_id)
|
||||
apply_wip_image_tags(
|
||||
self.session, [record.id], self._wip_tag_id, source=source
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — a tag must never fail an import
|
||||
log.warning(
|
||||
"wip-title auto-tag failed for image %s: %s", record.id, exc
|
||||
|
||||
@@ -39,7 +39,7 @@ from ...models import (
|
||||
TagPositiveConfirmation,
|
||||
TagSuggestionRejection,
|
||||
)
|
||||
from ...models.tag import PRESENTATION_SYSTEM_TAGS, image_tag
|
||||
from ...models.tag import CHROME_SYSTEM_TAGS, PROCESS_SYSTEM_TAGS, image_tag
|
||||
from .training_data import (
|
||||
_AUTO_SOURCES,
|
||||
_auto_apply_point,
|
||||
@@ -759,18 +759,42 @@ def auto_apply_sweep(
|
||||
|
||||
|
||||
_PRESENTATION_SOURCE = "presentation_auto"
|
||||
_PROCESS_SOURCE = "process_auto"
|
||||
|
||||
# System-tag auto-apply modes (#1464). Both modes run the identical sweep — apply
|
||||
# a system tag at a flat threshold with a PROVISIONAL source + a ring-loud review
|
||||
# guard — and differ ONLY in which tags, which settings knobs, and which
|
||||
# source/review-mode. 'chrome' (banner) is HIDDEN from the gallery; 'process'
|
||||
# (wip / editor screenshot) stays VISIBLE (the hide is a gallery-query effect of
|
||||
# the tag's group membership, not of this sweep).
|
||||
_SWEEP_MODES = {
|
||||
"chrome": {
|
||||
"names": CHROME_SYSTEM_TAGS,
|
||||
"enabled": "presentation_auto_apply_enabled",
|
||||
"threshold": "presentation_auto_apply_threshold",
|
||||
"conflict": "presentation_conflict_threshold",
|
||||
"source": _PRESENTATION_SOURCE,
|
||||
},
|
||||
"process": {
|
||||
"names": PROCESS_SYSTEM_TAGS,
|
||||
"enabled": "process_auto_apply_enabled",
|
||||
"threshold": "process_auto_apply_threshold",
|
||||
"conflict": "process_conflict_threshold",
|
||||
"source": _PROCESS_SOURCE,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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)."""
|
||||
def _system_tag_heads(session: Session, embedding_version: str, names):
|
||||
"""Trained heads for a system-tag group (chrome banner / process wip+editor).
|
||||
They fire at the group's FLAT threshold regardless of graduation — a head
|
||||
exists once the operator has labelled enough (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))
|
||||
.where(Tag.name.in_(names))
|
||||
).all()
|
||||
|
||||
|
||||
@@ -802,27 +826,33 @@ def _valued_image_ids(session: Session) -> set[int]:
|
||||
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}."""
|
||||
def system_tag_auto_apply_sweep(
|
||||
session: Session, *, mode: str, dry_run: bool = False
|
||||
) -> dict:
|
||||
"""Auto-apply a system-tag group at its FLAT threshold. mode='chrome' (banner,
|
||||
#141) hides the image; mode='process' (wip / editor screenshot, #1464) keeps it
|
||||
VISIBLE — the ONLY difference is the tag group's gallery membership, not this
|
||||
sweep. Two guards keep it safe: (1) never touch an image carrying a
|
||||
human/confirmed content tag; (2) if the image ALSO scores >= the conflict
|
||||
threshold on a content head, still apply but flag it (PresentationReview,
|
||||
mode=<mode>) so the review strip surfaces "also looks like <X>". The source is
|
||||
PROVISIONAL so the head never trains on its own output. No-op unless the mode's
|
||||
enabled flag is set. numpy-only (no sklearn). Returns {n_applied, n_flagged,
|
||||
concepts}."""
|
||||
import numpy as np
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
cfg = _SWEEP_MODES[mode]
|
||||
settings = _settings(session)
|
||||
if not dry_run and not settings.presentation_auto_apply_enabled:
|
||||
if not dry_run and not getattr(settings, cfg["enabled"]):
|
||||
return {"n_applied": 0, "n_flagged": 0, "concepts": []}
|
||||
ver = settings.embedder_model_version
|
||||
pres = _presentation_heads(session, ver)
|
||||
pres = _system_tag_heads(session, ver, cfg["names"])
|
||||
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)
|
||||
thr = float(getattr(settings, cfg["threshold"]))
|
||||
conflict_thr = float(getattr(settings, cfg["conflict"]))
|
||||
source = cfg["source"]
|
||||
|
||||
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)
|
||||
@@ -884,11 +914,13 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
|
||||
pg_insert(image_tag)
|
||||
.values(
|
||||
image_record_id=iid, tag_id=tid,
|
||||
source=_PRESENTATION_SOURCE,
|
||||
source=source,
|
||||
)
|
||||
.on_conflict_do_nothing()
|
||||
)
|
||||
# Guard 2: also looks like content → hide but flag for review.
|
||||
# Guard 2: also looks like real content → still apply, but flag it
|
||||
# for the review strip instead of silently marking (chrome hides,
|
||||
# process stays visible — either way the operator gets a heads-up).
|
||||
if Wc is not None and float(max_c[idx]) >= conflict_thr:
|
||||
n_flagged += 1
|
||||
if not dry_run:
|
||||
@@ -898,6 +930,7 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
|
||||
image_record_id=iid, tag_id=tid,
|
||||
conflict_tag_id=conf_tag_ids[int(arg_c[idx])],
|
||||
conflict_score=float(max_c[idx]),
|
||||
mode=mode,
|
||||
)
|
||||
.on_conflict_do_nothing()
|
||||
)
|
||||
@@ -914,6 +947,74 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
|
||||
}
|
||||
|
||||
|
||||
def soft_wip_conflict_audit(session: Session, dry_run: bool = False) -> dict:
|
||||
"""Ring-loud audit for the SOFT WIP-title cohort (#1474). Images auto-tagged
|
||||
`wip` from a low-precision sketch/doodle title (source='wip_title_soft') that ALSO
|
||||
score >= the process conflict threshold on a content head are probably FINISHED
|
||||
art mis-tagged as process — flag them (PresentationReview, mode='process') so the
|
||||
review strip surfaces them ("also looks like <X>", Keep tag / Remove tag). Does
|
||||
NOT remove the tag; the operator decides. No-op when there are no content heads.
|
||||
numpy-only. Returns {n_scanned, n_flagged}."""
|
||||
import numpy as np
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from ..wip_title import WIP_TITLE_SOFT_SOURCE, resolve_wip_tag_id
|
||||
|
||||
settings = _settings(session)
|
||||
ver = settings.embedder_model_version
|
||||
conflict_thr = float(settings.process_conflict_threshold)
|
||||
conf = _conflict_heads(session, ver)
|
||||
wip_id = resolve_wip_tag_id(session)
|
||||
if not conf or wip_id is None:
|
||||
return {"n_scanned": 0, "n_flagged": 0}
|
||||
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]
|
||||
|
||||
soft_ids = [iid for (iid,) in session.execute(
|
||||
select(image_tag.c.image_record_id)
|
||||
.where(image_tag.c.tag_id == wip_id)
|
||||
.where(image_tag.c.source == WIP_TITLE_SOFT_SOURCE)
|
||||
)]
|
||||
# Skip images already flagged for this tag (idempotent re-runs).
|
||||
flagged = {iid for (iid,) in session.execute(
|
||||
select(PresentationReview.image_record_id)
|
||||
.where(PresentationReview.tag_id == wip_id)
|
||||
)}
|
||||
soft_ids = [i for i in soft_ids if i not in flagged]
|
||||
|
||||
n_flagged = 0
|
||||
scanned = 0
|
||||
for start in range(0, len(soft_ids), _AUTO_APPLY_CHUNK):
|
||||
chunk = soft_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
|
||||
scanned += len(cids)
|
||||
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
|
||||
cprobs = 1.0 / (1.0 + np.exp(-(Xn @ Wc.T + bc)))
|
||||
max_c = cprobs.max(axis=1)
|
||||
arg_c = cprobs.argmax(axis=1)
|
||||
for k in range(len(cids)):
|
||||
if float(max_c[k]) >= conflict_thr:
|
||||
n_flagged += 1
|
||||
if not dry_run:
|
||||
session.execute(
|
||||
pg_insert(PresentationReview)
|
||||
.values(
|
||||
image_record_id=cids[k], tag_id=wip_id,
|
||||
conflict_tag_id=conf_tag_ids[int(arg_c[k])],
|
||||
conflict_score=float(max_c[k]),
|
||||
mode="process",
|
||||
)
|
||||
.on_conflict_do_nothing()
|
||||
)
|
||||
if not dry_run:
|
||||
session.commit()
|
||||
return {"n_scanned": scanned, "n_flagged": n_flagged}
|
||||
|
||||
|
||||
def retract_auto_applied_heads(session: Session) -> int:
|
||||
"""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
|
||||
|
||||
@@ -29,7 +29,15 @@ from ...models.tag import image_tag
|
||||
# a CCIP reference) unless the operator confirms them (milestone 139). Keeping
|
||||
# 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.
|
||||
_AUTO_SOURCES = ("head_auto", "ccip_auto", "ml_auto", "presentation_auto")
|
||||
# `process_auto` (#1464): wip/editor screenshot applied by the process sweep are
|
||||
# ALSO provisional — the head must learn only from title (`wip_title`) + manual
|
||||
# labels, never its own auto-applied output, or it would runaway (operator 2026-07-12).
|
||||
# `wip_title_soft` (#1474): the soft title tier (sketch/doodle) is LOW-precision, so
|
||||
# it's provisional too — a finished piece titled "sketch" must not train the wip head.
|
||||
_AUTO_SOURCES = (
|
||||
"head_auto", "ccip_auto", "ml_auto", "presentation_auto", "process_auto",
|
||||
"wip_title_soft",
|
||||
)
|
||||
|
||||
|
||||
def _hygiene_excluded_ids(session: Session) -> set[int]:
|
||||
|
||||
@@ -27,7 +27,13 @@ from ..models.tag import WIP_SYSTEM_TAG, Tag, image_tag
|
||||
|
||||
# image_tag.source stamped on title-heuristic WIP tags — distinct from the other
|
||||
# apply sources so provenance stays legible and a future undo can target only these.
|
||||
# HARD tier ("WIP"/"work in progress") is high-precision → trains the wip head.
|
||||
WIP_TITLE_SOURCE = "wip_title"
|
||||
# SOFT tier (sketch/doodle/scribble, #1474) is LOWER-precision — a finished "sketch"
|
||||
# is often not WIP. This source is PROVISIONAL (in training_data._AUTO_SOURCES) so it
|
||||
# NEVER trains the wip head; a soft-tagged image that also looks like real content is
|
||||
# surfaced by the ring-loud audit for review.
|
||||
WIP_TITLE_SOFT_SOURCE = "wip_title_soft"
|
||||
|
||||
# A standalone "WIP" / "W.I.P" token, or the phrase "work in progress"
|
||||
# (space/underscore/hyphen separated). The letter-boundary lookarounds are what
|
||||
@@ -39,11 +45,20 @@ _WIP_RE = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Coarse SQL prefilter for the backfill sweep — narrows the post scan to rows that
|
||||
# Soft tier: sketch / doodle / scribble (+ plurals), letter-boundary anchored so
|
||||
# "sketchbook" / "kadoodle" don't trip it. Deliberately conservative — recall is
|
||||
# secondary because the soft source doesn't train the head and the ring-loud audit
|
||||
# catches false positives.
|
||||
_SOFT_WIP_RE = re.compile(
|
||||
r"(?<![A-Za-z])(?:sketch|sketches|doodle|doodles|scribble|scribbles)(?![A-Za-z])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Coarse SQL prefilters for the backfill sweep — narrow the post scan to rows that
|
||||
# COULD match before the precise regex confirms. Case-insensitive ILIKE patterns.
|
||||
# MUST stay a SUPERSET of _WIP_RE (every regex match contains "wip" or
|
||||
# "work…progress") or the sweep would silently miss posts.
|
||||
# Each MUST stay a SUPERSET of its regex or the sweep would silently miss posts.
|
||||
WIP_TITLE_SQL_PREFILTER = ("%wip%", "%work%progress%")
|
||||
SOFT_WIP_TITLE_SQL_PREFILTER = ("%sketch%", "%doodle%", "%scribble%")
|
||||
|
||||
# Chunk bulk inserts so a large sweep can't blow past psycopg's 65535-parameter
|
||||
# ceiling (3 params/row → ~21k rows max; 5k stays comfortably under).
|
||||
@@ -51,12 +66,19 @@ _INSERT_CHUNK = 5000
|
||||
|
||||
|
||||
def matches_wip_title(title: str | None) -> bool:
|
||||
"""True when a post title explicitly marks it work-in-progress."""
|
||||
"""True when a post title explicitly marks it work-in-progress (HARD tier)."""
|
||||
if not title:
|
||||
return False
|
||||
return _WIP_RE.search(title) is not None
|
||||
|
||||
|
||||
def matches_soft_wip_title(title: str | None) -> bool:
|
||||
"""True when a title carries a SOFT WIP cue (sketch/doodle/scribble, #1474)."""
|
||||
if not title:
|
||||
return False
|
||||
return _SOFT_WIP_RE.search(title) is not None
|
||||
|
||||
|
||||
def resolve_wip_tag_id(session: Session) -> int | None:
|
||||
"""The seeded ``wip`` system tag's id (migration 0075), or None if absent."""
|
||||
return session.execute(
|
||||
@@ -64,8 +86,10 @@ def resolve_wip_tag_id(session: Session) -> int | None:
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def apply_wip_image_tags(session: Session, image_ids, tag_id: int) -> int:
|
||||
"""Attach ``tag_id`` (source='wip_title') to each image id, idempotently —
|
||||
def apply_wip_image_tags(
|
||||
session: Session, image_ids, tag_id: int, *, source: str = WIP_TITLE_SOURCE
|
||||
) -> int:
|
||||
"""Attach ``tag_id`` (stamped with ``source``) to each image id, idempotently —
|
||||
never disturbs an existing tag or its source. Returns the number of image_tag
|
||||
rows newly inserted. Does NOT commit.
|
||||
|
||||
@@ -92,7 +116,7 @@ def apply_wip_image_tags(session: Session, image_ids, tag_id: int) -> int:
|
||||
session.execute(
|
||||
pg_insert(image_tag)
|
||||
.values([
|
||||
{"image_record_id": iid, "tag_id": tag_id, "source": WIP_TITLE_SOURCE}
|
||||
{"image_record_id": iid, "tag_id": tag_id, "source": source}
|
||||
for iid in to_insert
|
||||
])
|
||||
.on_conflict_do_nothing(index_elements=["image_record_id", "tag_id"])
|
||||
|
||||
@@ -1047,6 +1047,41 @@ def cleanup_old_download_events() -> int:
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
def _backfill_wip_tier(session, tag_id, prefilter, matcher, source) -> int:
|
||||
"""One keyset-paginated pass over posts whose title matches a WIP tier, applying
|
||||
`tag_id` (stamped `source`) to their images. Shared by the hard + soft tiers
|
||||
(#1458 / #1474). Coarse `prefilter` (ILIKE superset) narrows the scan; the precise
|
||||
`matcher` confirms. Idempotent-additive (ON CONFLICT DO NOTHING). Returns the row
|
||||
count newly applied."""
|
||||
from ..models import Post
|
||||
from ..models.image_provenance import ImageProvenance
|
||||
from ..services.wip_title import apply_wip_image_tags
|
||||
|
||||
applied = 0
|
||||
last_id = 0
|
||||
while True:
|
||||
rows = session.execute(
|
||||
select(Post.id, Post.post_title)
|
||||
.where(Post.id > last_id)
|
||||
.where(Post.post_title.is_not(None))
|
||||
.where(or_(*[Post.post_title.ilike(p) for p in prefilter]))
|
||||
.order_by(Post.id.asc())
|
||||
.limit(WIP_BACKFILL_PAGE)
|
||||
).all()
|
||||
if not rows:
|
||||
break
|
||||
last_id = rows[-1][0]
|
||||
match_ids = [pid for pid, title in rows if matcher(title)]
|
||||
if match_ids:
|
||||
image_ids = session.execute(
|
||||
select(ImageProvenance.image_record_id)
|
||||
.where(ImageProvenance.post_id.in_(match_ids))
|
||||
).scalars().all()
|
||||
applied += apply_wip_image_tags(session, image_ids, tag_id, source=source)
|
||||
session.commit()
|
||||
return applied
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.maintenance.backfill_wip_title_tags",
|
||||
# Coarse-prefiltered scan over posts; the candidate set is small on a typical
|
||||
@@ -1054,32 +1089,32 @@ def cleanup_old_download_events() -> int:
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def backfill_wip_title_tags() -> int:
|
||||
"""Scan EXISTING posts for explicit WIP titles and apply the `wip` system tag
|
||||
to their images — the operator-triggered back-catalogue catch-up for
|
||||
title-based WIP tagging (task #1458). New imports are tagged live by the
|
||||
importer; this covers everything already in the library.
|
||||
"""Scan EXISTING posts for WIP titles and apply the `wip` system tag to their
|
||||
images — the operator-triggered back-catalogue catch-up (task #1458 hard tier +
|
||||
#1474 soft tier). New imports are tagged live by the importer; this covers the
|
||||
existing library.
|
||||
|
||||
Keyset-paginated over posts (restart-safe). A coarse SQL prefilter narrows to
|
||||
titles that COULD match; the precise regex (matches_wip_title) confirms.
|
||||
Idempotent-additive (ON CONFLICT DO NOTHING) — never disturbs an existing tag.
|
||||
HARD tier ("WIP"/"work in progress") always runs (the operator triggered the
|
||||
scan); the SOFT tier (sketch/doodle, provisional source) runs only when
|
||||
wip_soft_title_tagging_enabled, AFTER hard so a title matching both keeps the
|
||||
trained hard tag (ON CONFLICT DO NOTHING). Keyset-paginated, restart-safe.
|
||||
|
||||
Deliberately NOT scheduled as a beat: a periodic re-run would re-apply to
|
||||
matching posts and silently undo a manual WIP removal, so it stays an explicit
|
||||
operator action (Settings → "Scan existing posts for WIP titles"). Returns the
|
||||
number of image-tag rows newly applied.
|
||||
Deliberately NOT scheduled as a beat: a periodic re-run would re-apply to matching
|
||||
posts and silently undo a manual WIP removal, so it stays an explicit operator
|
||||
action (Settings → "Scan existing posts for WIP titles"). Returns rows applied.
|
||||
"""
|
||||
from ..models import Post
|
||||
from ..models.image_provenance import ImageProvenance
|
||||
from ..models import ImportSettings
|
||||
from ..services.wip_title import (
|
||||
SOFT_WIP_TITLE_SQL_PREFILTER,
|
||||
WIP_TITLE_SOFT_SOURCE,
|
||||
WIP_TITLE_SOURCE,
|
||||
WIP_TITLE_SQL_PREFILTER,
|
||||
apply_wip_image_tags,
|
||||
matches_soft_wip_title,
|
||||
matches_wip_title,
|
||||
resolve_wip_tag_id,
|
||||
)
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
applied = 0
|
||||
last_id = 0
|
||||
with SessionLocal() as session:
|
||||
tag_id = resolve_wip_tag_id(session)
|
||||
if tag_id is None:
|
||||
@@ -1087,30 +1122,16 @@ def backfill_wip_title_tags() -> int:
|
||||
"backfill_wip_title_tags: no `wip` system tag present; nothing to do"
|
||||
)
|
||||
return 0
|
||||
like_a, like_b = WIP_TITLE_SQL_PREFILTER
|
||||
while True:
|
||||
rows = session.execute(
|
||||
select(Post.id, Post.post_title)
|
||||
.where(Post.id > last_id)
|
||||
.where(Post.post_title.is_not(None))
|
||||
.where(or_(
|
||||
Post.post_title.ilike(like_a),
|
||||
Post.post_title.ilike(like_b),
|
||||
))
|
||||
.order_by(Post.id.asc())
|
||||
.limit(WIP_BACKFILL_PAGE)
|
||||
).all()
|
||||
if not rows:
|
||||
break
|
||||
last_id = rows[-1][0]
|
||||
match_ids = [pid for pid, title in rows if matches_wip_title(title)]
|
||||
if match_ids:
|
||||
image_ids = session.execute(
|
||||
select(ImageProvenance.image_record_id)
|
||||
.where(ImageProvenance.post_id.in_(match_ids))
|
||||
).scalars().all()
|
||||
applied += apply_wip_image_tags(session, image_ids, tag_id)
|
||||
session.commit()
|
||||
settings = ImportSettings.load_sync(session)
|
||||
applied = _backfill_wip_tier(
|
||||
session, tag_id, WIP_TITLE_SQL_PREFILTER, matches_wip_title,
|
||||
WIP_TITLE_SOURCE,
|
||||
)
|
||||
if settings.wip_soft_title_tagging_enabled:
|
||||
applied += _backfill_wip_tier(
|
||||
session, tag_id, SOFT_WIP_TITLE_SQL_PREFILTER, matches_soft_wip_title,
|
||||
WIP_TITLE_SOFT_SOURCE,
|
||||
)
|
||||
if applied:
|
||||
log.info("backfill_wip_title_tags: applied wip to %d image(s)", applied)
|
||||
return applied
|
||||
|
||||
+42
-6
@@ -599,18 +599,54 @@ def scheduled_ccip_auto_apply() -> str:
|
||||
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
|
||||
"""Auto-hide presentation chrome (banner) on a daily passive sweep (#141).
|
||||
No-op unless presentation_auto_apply_enabled. Idempotent — already-tagged 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 system_tag_auto_apply_sweep
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
result = presentation_auto_apply_sweep(session)
|
||||
result = system_tag_auto_apply_sweep(session, mode="chrome")
|
||||
return f"applied={result['n_applied']} flagged={result['n_flagged']}"
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.ml.scheduled_process_auto_apply",
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def scheduled_process_auto_apply() -> str:
|
||||
"""Auto-apply the PROCESS system tags (wip / editor screenshot) on a daily
|
||||
passive sweep (#1464) — provisional source, ring-loud review guard, image stays
|
||||
VISIBLE. No-op unless process_auto_apply_enabled (opt-in). Idempotent —
|
||||
already-tagged/rejected images are skipped — so an interrupted run just re-runs
|
||||
next cycle (the recovery). Wall-clock bounded by the task time limits."""
|
||||
from ..services.ml.heads import system_tag_auto_apply_sweep
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
result = system_tag_auto_apply_sweep(session, mode="process")
|
||||
return f"applied={result['n_applied']} flagged={result['n_flagged']}"
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.ml.scheduled_soft_wip_conflict_audit",
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def scheduled_soft_wip_conflict_audit() -> str:
|
||||
"""Ring-loud audit over the SOFT WIP-title cohort (#1474) — flag sketch/doodle
|
||||
auto-tags that ALSO look like real content for review. No-op when there are no
|
||||
content heads; idempotent (already-flagged images skipped). Runs regardless of
|
||||
the process-sweep toggle, since soft-title tags come from the importer, not that
|
||||
sweep. Wall-clock bounded by the task time limits."""
|
||||
from ..services.ml.heads import soft_wip_conflict_audit
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
result = soft_wip_conflict_audit(session)
|
||||
return f"scanned={result['n_scanned']} 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
|
||||
|
||||
@@ -86,7 +86,16 @@ const PLATFORMS = {
|
||||
* script to decide whether to show the floating "Add as source" button.
|
||||
*/
|
||||
const PLATFORM_ARTIST_PATTERNS = {
|
||||
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c\/)[^/?#]+\/?$/i,
|
||||
// Patreon serves the same creator under three URL shapes (see backend
|
||||
// patreon_resolver._VANITY_RE): bare `patreon.com/Atole`, `c/` prefix, and
|
||||
// `cw/` "creator workspace" — the last is the URL you land on once you're
|
||||
// SUBSCRIBED, which is exactly when the button matters. Match all three, and
|
||||
// drop the single-segment end-anchor so a creator's inner page
|
||||
// (…/cw/Atole/posts, …/Atole/membership) also injects the button. Nav pages
|
||||
// (home/search/…/posts permalink) stay excluded. Mirrors extension_service
|
||||
// ._PLATFORM_PATTERNS — keep in sync (operator-flagged 2026-07-13: button
|
||||
// vanished once subscribed because the old pattern only matched the bare root).
|
||||
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?:cw\/|c\/)?(?!(?:home|search|messages|notifications|library|settings|posts)(?:[\/?#]|$))[^/?#]+/i,
|
||||
subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i,
|
||||
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i,
|
||||
deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FabledCurator",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.8",
|
||||
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||
|
||||
"browser_specific_settings": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fabledcurator-extension",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.8",
|
||||
"private": true,
|
||||
"description": "Firefox extension for FabledCurator",
|
||||
"scripts": {
|
||||
@@ -10,6 +10,6 @@
|
||||
"sign": "web-ext sign --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET"
|
||||
},
|
||||
"devDependencies": {
|
||||
"web-ext": "^8.0.0"
|
||||
"web-ext": "^10.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -13,16 +13,16 @@
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.0",
|
||||
"vuetify": "^3.5.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^5.0.0",
|
||||
"pinia": "^3.0.0",
|
||||
"vuetify": "^4.0.0",
|
||||
"@mdi/font": "^7.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-vuetify": "^2.0.0",
|
||||
"vite-plugin-vuetify": "^2.1.0",
|
||||
"sass": "^1.71.0",
|
||||
"vitest": "^4.0.0",
|
||||
"@vue/test-utils": "^2.4.0",
|
||||
|
||||
@@ -18,11 +18,11 @@ const route = useRoute()
|
||||
<style scoped>
|
||||
.fc-content {
|
||||
min-height: 100vh;
|
||||
/* Push initial viewport content below the sticky TopNav. Without
|
||||
this, some views' first rows / form fields / table headers can
|
||||
end up obscured by the navbar (depending on parent overflow
|
||||
context interacting with position: sticky). Scrolled-down content
|
||||
still slides under the nav — the gradient-fade design is intact. */
|
||||
padding-top: 64px;
|
||||
/* NO padding-top: the TopNav is position:sticky, so it already reserves its
|
||||
own space in the v-app flex column — content flows directly below it. The
|
||||
old 64px padding-top was a leftover from a FIXED navbar and double-counted
|
||||
that space, leaving a large empty band at the top of EVERY view (and pushing
|
||||
the full-height calc(100vh - 64px) views down so they overflowed). Removed
|
||||
2026-07-13. Scrolled content still slides under the sticky nav as before. */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<v-snackbar
|
||||
v-model="show" :color="color" location="bottom right" timeout="4000"
|
||||
multi-line elevation="4"
|
||||
min-height="68" elevation="4"
|
||||
>
|
||||
{{ message }}
|
||||
<template #actions>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<header class="fc-topnav">
|
||||
<header ref="navEl" class="fc-topnav" :class="{ 'fc-topnav--chrome': hasStickyChrome }">
|
||||
<div class="fc-nav-left">
|
||||
<RouterLink :to="FRONT_DOOR" class="fc-brand" aria-label="FabledCurator home">
|
||||
<img src="/favicon.svg" alt="" class="fc-brand__glyph" width="22" height="22" />
|
||||
@@ -64,13 +64,39 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import router, { FRONT_DOOR } from '../router.js'
|
||||
import { useSystemStore } from '../stores/system.js'
|
||||
import PipelineStatusChip from './PipelineStatusChip.vue'
|
||||
|
||||
const system = useSystemStore()
|
||||
onMounted(() => system.refreshHealth())
|
||||
|
||||
// Publish the nav's REAL height as --fc-nav-h so full-height workspaces
|
||||
// (Explore/Subscriptions) and sticky sub-headers pin to it exactly instead of a
|
||||
// hardcoded 64px that Vuetify 4's MD3 sizing broke — the Explore breadcrumb was
|
||||
// tucking under a taller nav (#1481). ResizeObserver keeps it live as the nav
|
||||
// reflows (per-view teleported actions, mobile breakpoint, chip state changes).
|
||||
const navEl = ref(null)
|
||||
let navRO = null
|
||||
onMounted(() => {
|
||||
system.refreshHealth()
|
||||
if (navEl.value && 'ResizeObserver' in window) {
|
||||
navRO = new ResizeObserver(() => {
|
||||
const h = navEl.value?.offsetHeight
|
||||
if (h) document.documentElement.style.setProperty('--fc-nav-h', `${h}px`)
|
||||
})
|
||||
navRO.observe(navEl.value)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => { navRO?.disconnect() })
|
||||
|
||||
// Views that pin a sticky sub-header (filter bar / tabs) directly under the nav
|
||||
// declare `meta.stickyChrome`. On those, the nav doesn't fade to transparent at
|
||||
// its bottom — it hands off at the shared seam alpha so the sub-header can
|
||||
// continue the SAME fade (see .fc-chrome-continues in app.css). One gradient.
|
||||
const route = useRoute()
|
||||
const hasStickyChrome = computed(() => !!route.meta?.stickyChrome)
|
||||
|
||||
// Every route with a meta.title is a nav entry. Order by meta.navOrder —
|
||||
// router.getRoutes() does NOT guarantee declaration order, so explicit numbers
|
||||
@@ -119,16 +145,35 @@ const health = computed(() => {
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
/* Obsidian (#14171A = 20,23,26) gradient fade — content scrolls under it. */
|
||||
/* Obsidian (#14171A) fade — content scrolls under it. Holds high (0.92 →
|
||||
0.84) through the top half, then eases to transparent over the bottom
|
||||
quarter so it tails off softly instead of a straight line to a hard edge
|
||||
(operator 2026-07-13). Shared --fc-chrome-rgb keeps it in sync with the
|
||||
sub-header continuation. */
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(20, 23, 26, 0.92) 0%,
|
||||
rgba(20, 23, 26, 0.65) 60%,
|
||||
rgba(20, 23, 26, 0) 100%
|
||||
rgba(var(--fc-chrome-rgb), 0.92) 0%,
|
||||
rgba(var(--fc-chrome-rgb), 0.84) 50%,
|
||||
rgba(var(--fc-chrome-rgb), 0.55) 75%,
|
||||
rgba(var(--fc-chrome-rgb), 0) 100%
|
||||
);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
/* On a view with a sticky sub-header pinned beneath (meta.stickyChrome), the nav
|
||||
stops fading at the shared seam alpha instead of going fully transparent — the
|
||||
sub-header (.fc-chrome-continues) picks the fade up from there, so the two read
|
||||
as one continuous gradient. Compound selector out-specifies .fc-topnav so it
|
||||
wins regardless of Vite's production CSS ordering. --fc-chrome-* come from the
|
||||
global :root in app.css (custom props inherit into scoped styles). */
|
||||
.fc-topnav.fc-topnav--chrome {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--fc-chrome-rgb), 0.92) 0%,
|
||||
rgba(var(--fc-chrome-rgb), 0.84) 60%,
|
||||
rgba(var(--fc-chrome-rgb), var(--fc-chrome-seam)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.fc-brand {
|
||||
display: flex;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
filter, applied retroactively to the existing library.
|
||||
</p>
|
||||
|
||||
<v-row dense>
|
||||
<v-row density="compact">
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model.number="minW" label="Min width (px)" type="number"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
cadence as the transparency audit.
|
||||
</p>
|
||||
|
||||
<v-row dense>
|
||||
<v-row density="compact">
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model.number="threshold" label="Threshold (0–1)"
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
@update:search="onSearch"
|
||||
@update:model-value="onPick"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps" :title="item.raw.name">
|
||||
<template #item="{ props: itemProps, internalItem }">
|
||||
<v-list-item v-bind="itemProps" :title="internalItem.raw.name">
|
||||
<template #subtitle>
|
||||
{{ item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind }}
|
||||
{{ internalItem.raw.fandom_name ? `character · ${internalItem.raw.fandom_name}` : internalItem.raw.kind }}
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="fc-filterbar-wrap">
|
||||
<div class="fc-filterbar-wrap fc-chrome-continues">
|
||||
<div class="fc-filterbar">
|
||||
<v-autocomplete
|
||||
v-model="selected"
|
||||
@@ -13,14 +13,14 @@
|
||||
@update:search="onSearch"
|
||||
@update:model-value="onPick"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps" :title="item.raw.name">
|
||||
<template #item="{ props: itemProps, internalItem }">
|
||||
<v-list-item v-bind="itemProps" :title="internalItem.raw.name">
|
||||
<template #prepend>
|
||||
<v-icon size="small">{{ iconFor(item.raw) }}</v-icon>
|
||||
<v-icon size="small">{{ iconFor(internalItem.raw) }}</v-icon>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
{{ item.raw.kind === 'artist' ? 'artist'
|
||||
: (item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind) }}
|
||||
{{ internalItem.raw.kind === 'artist' ? 'artist'
|
||||
: (internalItem.raw.fandom_name ? `character · ${internalItem.raw.fandom_name}` : internalItem.raw.kind) }}
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
@@ -306,27 +306,17 @@ function pushFilter(mutate) {
|
||||
frosted block pinned directly under the 64px TopNav and continuous with it. */
|
||||
.fc-filterbar-wrap {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
|
||||
z-index: 5;
|
||||
/* Attach to the TopNav: cancel the v-container's top padding (pt-2 = 8px)
|
||||
so the bar sits flush at 64px even at scroll 0 — without this it detaches
|
||||
and a gap shows through when scrolled to the top. */
|
||||
margin-top: -8px;
|
||||
margin-bottom: 12px;
|
||||
/* EXACT same gradiated obsidian (#14171A = 20,23,26) frost as the TopNav so
|
||||
the two read as one continuous piece of chrome — images scroll visibly
|
||||
under both. The nav's gradient fades to transparent at ITS bottom; this
|
||||
bar re-darkens at its top, so a faint seam (the page/image showing through
|
||||
the nav's transparent edge) separates them when scrolled to the very top,
|
||||
while under-scroll they frost as one. */
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(20, 23, 26, 0.92) 0%,
|
||||
rgba(20, 23, 26, 0.65) 60%,
|
||||
rgba(20, 23, 26, 0) 100%
|
||||
);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
/* The frost itself (obsidian fade + blur) is the shared .fc-chrome-continues
|
||||
primitive: it CONTINUES the TopNav's fade from the seam alpha to transparent
|
||||
rather than re-darkening, so the nav + bar read as one gradient (operator
|
||||
2026-07-13). This block only owns the sticky positioning now. */
|
||||
}
|
||||
.fc-filterbar {
|
||||
display: flex;
|
||||
@@ -341,6 +331,20 @@ function pushFilter(mutate) {
|
||||
.fc-filterbar-wrap :deep(.v-btn-group) {
|
||||
background-color: rgba(20, 23, 26, 0.72);
|
||||
}
|
||||
/* Media toggle (All / Images / Videos) as ONE cohesive segmented control.
|
||||
FC's global VBtn { rounded: 'pill' } default made Vuetify 4 pill-round each
|
||||
SEGMENT individually, so the rounded ends collided at the joins — the shapes
|
||||
landed awkwardly on the button edges (operator 2026-07-13). Square the inner
|
||||
segments (over the pill utility's !important) and clip the group to a single
|
||||
8px outline (matches the chips/tiles rounding elsewhere in the app). Radius
|
||||
only — no height change, so the bar height and nav offset are untouched. */
|
||||
.fc-filterbar-wrap :deep(.v-btn-toggle) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-filterbar-wrap :deep(.v-btn-toggle .v-btn) {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
.fc-filterbar__search { max-width: 320px; min-width: 200px; }
|
||||
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
/* The tag chips' bodies toggle include/exclude — signal they're clickable. */
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<template>
|
||||
<!-- Auto-hidden chrome that ALSO looked like real content — surfaced PROACTIVELY
|
||||
atop the gallery whenever there's something to review (NOT gated on the
|
||||
Show-hidden toggle, so misfires can't go unnoticed), 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">
|
||||
<!-- System-tag auto-applies (chrome hides / process WIP tags) that ALSO looked
|
||||
like real content — surfaced PROACTIVELY atop the gallery whenever there's
|
||||
something to review (NOT gated on the Show-hidden toggle, so misfires can't
|
||||
go unnoticed), most-concerning first, with keep / remove (#141, #1464).
|
||||
Renders nothing when there's nothing to review. -->
|
||||
<section v-if="items.length" class="fc-review" aria-label="Auto-tagged 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
|
||||
{{ items.length }} auto-tagged {{ items.length === 1 ? 'image' : 'images' }}
|
||||
may be real content — review
|
||||
</span>
|
||||
</div>
|
||||
<div class="fc-review__cards">
|
||||
@@ -26,16 +27,16 @@
|
||||
>
|
||||
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__tag">{{ tagLine(it) }}</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>
|
||||
>{{ keepLabel(it) }}</button>
|
||||
<button
|
||||
type="button" class="fc-review-btn fc-review-btn--unhide"
|
||||
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'unhide')"
|
||||
>Un-hide</button>
|
||||
>{{ removeLabel(it) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,6 +55,11 @@ const items = ref([])
|
||||
const busy = ref([])
|
||||
|
||||
function keyOf(it) { return `${it.image_id}:${it.tag_id}` }
|
||||
// Chrome flags hide the image (keep-hidden / un-hide); process flags leave it
|
||||
// visible and just tagged (keep-tag / remove-tag). Same endpoints, different words.
|
||||
function tagLine(it) { return (it.mode === 'process' ? 'auto-tagged ' : 'hidden as ') + it.tag_name }
|
||||
function keepLabel(it) { return it.mode === 'process' ? 'Keep tag' : 'Keep hidden' }
|
||||
function removeLabel(it) { return it.mode === 'process' ? 'Remove tag' : 'Un-hide' }
|
||||
|
||||
async function load() {
|
||||
// Fetched unconditionally on mount — the strip prompts for pending misfires
|
||||
@@ -71,7 +77,8 @@ async function resolve(it, action) {
|
||||
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' })
|
||||
const verb = it.mode === 'process' ? 'Removed' : 'Un-hidden'
|
||||
toast({ text: `${verb} — “${it.tag_name}” removed; it'll train the head`, type: 'success' })
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
|
||||
@@ -171,13 +171,13 @@
|
||||
/>
|
||||
</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
|
||||
Auto-hide <code>banner</code> chrome from the gallery once a head has
|
||||
learned it (≥ {{ 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.
|
||||
(<code>wip</code> and <code>editor screenshot</code> are handled by the
|
||||
process auto-tagger below.) If a hidden image also looks like real content
|
||||
(≥ {{ Math.round((presentationConflictInput || 0) * 100) }}% on a content
|
||||
tag), it's flagged for review instead of buried. Every auto-hide is reversible.
|
||||
</p>
|
||||
<div class="d-flex mb-3" style="gap: 12px;">
|
||||
<v-text-field
|
||||
@@ -195,6 +195,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Process auto-tagging (#1464): wip / editor screenshot -->
|
||||
<div class="fc-auto mt-6">
|
||||
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
||||
<v-icon size="18" color="accent">mdi-progress-wrench</v-icon>
|
||||
<span class="fc-section-h">Auto-tag work-in-progress</span>
|
||||
<v-switch
|
||||
v-model="processEnabled" :loading="settingBusy" hide-details
|
||||
density="compact" color="success" class="ml-auto"
|
||||
@update:model-value="onToggleProcess"
|
||||
/>
|
||||
</div>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Auto-tag <code>wip</code> and <code>editor screenshot</code> process art
|
||||
once a head has learned them (≥ {{ minPositives }} examples) and clears
|
||||
{{ Math.round((processThresholdInput || 0) * 100) }}% confidence. These stay
|
||||
<strong>visible</strong> in the gallery — the tag just keeps them out of
|
||||
training and the Explore rabbit-hole. Off by default. If a tagged image also
|
||||
looks like real content (≥ {{ Math.round((processConflictInput || 0) * 100) }}%
|
||||
on a content tag), it's flagged for review. Learns only from your titles +
|
||||
manual tags, never its own guesses — so it can't run away. Every tag reversible.
|
||||
</p>
|
||||
<div class="d-flex mb-3" style="gap: 12px;">
|
||||
<v-text-field
|
||||
v-model.number="processThresholdInput" label="Tag confidence"
|
||||
type="number" min="0.5" max="0.999" step="0.01" density="compact"
|
||||
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
||||
@change="onSaveProcess"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="processConflictInput" label="Flag if content ≥"
|
||||
type="number" min="0" max="1" step="0.05" density="compact"
|
||||
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
||||
@change="onSaveProcess"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance / tuning -->
|
||||
<div v-if="metricsConcepts.length" class="mt-5">
|
||||
<div class="fc-section-h mb-1">How auto-apply is landing</div>
|
||||
@@ -258,6 +295,9 @@ let autoTimer = null
|
||||
const presentationEnabled = ref(true)
|
||||
const presentationThresholdInput = ref(0.90)
|
||||
const presentationConflictInput = ref(0.50)
|
||||
const processEnabled = ref(false)
|
||||
const processThresholdInput = ref(0.90)
|
||||
const processConflictInput = ref(0.50)
|
||||
|
||||
const autoRunning = computed(() => autoStatus.value?.running_id != null)
|
||||
const lastSweep = computed(() =>
|
||||
@@ -292,6 +332,9 @@ onMounted(async () => {
|
||||
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
|
||||
processEnabled.value = s.process_auto_apply_enabled ?? false
|
||||
processThresholdInput.value = s.process_auto_apply_threshold ?? 0.90
|
||||
processConflictInput.value = s.process_conflict_threshold ?? 0.50
|
||||
} catch { /* non-fatal */ }
|
||||
await refresh()
|
||||
if (running.value) startPoll()
|
||||
@@ -402,6 +445,32 @@ async function onSavePresentation() {
|
||||
settingBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleProcess(val) {
|
||||
settingBusy.value = true
|
||||
try {
|
||||
await mlSettings.patchSettings({ process_auto_apply_enabled: !!val })
|
||||
toast({ text: val ? 'WIP auto-tag on' : 'WIP auto-tag off', type: 'success' })
|
||||
} catch (e) {
|
||||
processEnabled.value = !val // revert the switch
|
||||
toast({ text: `Could not update: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
settingBusy.value = false
|
||||
}
|
||||
}
|
||||
async function onSaveProcess() {
|
||||
settingBusy.value = true
|
||||
try {
|
||||
await mlSettings.patchSettings({
|
||||
process_auto_apply_threshold: Number(processThresholdInput.value),
|
||||
process_conflict_threshold: Number(processConflictInput.value),
|
||||
})
|
||||
} catch (e) {
|
||||
toast({ text: `Could not save: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
settingBusy.value = false
|
||||
}
|
||||
}
|
||||
function onPreview() { startSweep(true) }
|
||||
function onApplyNow() { startSweep(false) }
|
||||
async function startSweep(dryRun) {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
being dropped as duplicates;</strong> raise it to collapse more
|
||||
look-alikes. Applies to new imports.
|
||||
</div>
|
||||
<v-row align="center" no-gutters>
|
||||
<v-row no-gutters class="align-center">
|
||||
<v-col cols="12" sm="9">
|
||||
<v-slider
|
||||
v-model="local.phash_threshold"
|
||||
@@ -99,6 +99,17 @@
|
||||
the Explore browse. Applies to new imports; run the scan below to catch
|
||||
posts already in your library.
|
||||
</div>
|
||||
<v-switch
|
||||
v-model="local.wip_soft_title_tagging_enabled"
|
||||
label="Also tag “sketch” / “doodle” titles (lower precision)"
|
||||
density="compact" hide-details color="primary" @change="save"
|
||||
/>
|
||||
<div class="fc-help mb-3">
|
||||
Extends the above to softer cues (<code>sketch</code>, <code>doodle</code>,
|
||||
<code>scribble</code>). These stay <strong>visible</strong> and never train
|
||||
the tagging model — a daily audit flags any that actually look like finished
|
||||
art for review. Off by default.
|
||||
</div>
|
||||
<v-btn
|
||||
variant="tonal" color="primary" size="small"
|
||||
:loading="store.wipScanBusy" prepend-icon="mdi-magnify"
|
||||
@@ -139,6 +150,7 @@ const local = reactive({
|
||||
skip_single_color: false, single_color_threshold: 0.95,
|
||||
phash_threshold: 10,
|
||||
wip_title_tagging_enabled: true,
|
||||
wip_soft_title_tagging_enabled: false,
|
||||
})
|
||||
|
||||
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-row dense>
|
||||
<v-row density="compact">
|
||||
<v-col v-for="card in cards" :key="card.label" cols="12" sm="6" md="4" lg="3" xl="2">
|
||||
<v-card class="fc-stat">
|
||||
<v-card-text>
|
||||
|
||||
@@ -20,7 +20,7 @@ const routes = [
|
||||
|
||||
// FC-2: image backbone
|
||||
{ path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase', navOrder: 10 } },
|
||||
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery', navOrder: 20 } },
|
||||
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery', navOrder: 20, stickyChrome: true } },
|
||||
// Explore: a 3-pane tagging workspace — walk an image's visual neighbours
|
||||
// (left) while tagging the focused image (center viewer + modal-parity tag
|
||||
// rail). Optional anchor param — the bare /explore nav entry SEEDS a random
|
||||
@@ -29,11 +29,11 @@ const routes = [
|
||||
// Browse hub (operator-asked 2026-06-09): Posts / Artists / Tags as tabs —
|
||||
// the three "browse the library by an axis" surfaces. One nav entry; the old
|
||||
// standalone paths redirect into the matching tab (below).
|
||||
{ path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse', navOrder: 30 } },
|
||||
{ path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse', navOrder: 30, stickyChrome: true } },
|
||||
// Artist detail — no meta.title (reached by clicking an artist, not nav).
|
||||
{ path: '/artist/:slug', name: 'artist', component: ArtistView },
|
||||
// Series browse — a nav entry (meta.title).
|
||||
{ path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series', navOrder: 40 } },
|
||||
{ path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series', navOrder: 40, stickyChrome: true } },
|
||||
// Series management — no meta.title (reached from a series card/tag).
|
||||
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
|
||||
// Series reader — immersive (no top nav, no meta.title).
|
||||
@@ -41,10 +41,10 @@ const routes = [
|
||||
|
||||
// FC-3: subscription backbone — purely management (sources/downloads),
|
||||
// distinct from the Browse hub.
|
||||
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions', navOrder: 50 } },
|
||||
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions', navOrder: 50, stickyChrome: true } },
|
||||
|
||||
// Settings — config, pinned to the right of the nav (TopNav special-cases it).
|
||||
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
|
||||
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings', stickyChrome: true } },
|
||||
|
||||
// The old standalone paths now redirect into the Browse hub, preserving any
|
||||
// deep-link query (e.g. /posts?post_id=N → /browse?tab=posts&post_id=N). The
|
||||
|
||||
@@ -25,6 +25,10 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
const cursor = ref(-1)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
// Reach (#1476): how far the walk reaches past the anchor's immediate cluster.
|
||||
// 0 = nearest (can get stuck in a dense signature); ~0.4 default mixes in
|
||||
// mid-far escape routes so the walk diversifies without hitting "Random image".
|
||||
const reach = ref(0.4)
|
||||
|
||||
const inflight = useInflightToken()
|
||||
|
||||
@@ -46,7 +50,13 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
const body = await api.get('/api/gallery/similar', {
|
||||
// exclude_wip: keep work-in-progress out of the Explore rabbit-hole
|
||||
// (the gallery's own "similar" button still shows it) — operator 2026-07-08.
|
||||
params: { similar_to: numId, limit: NEIGHBOR_LIMIT, exclude_wip: 1 },
|
||||
// reach + exclude_ids (#1476): reach past the dense cluster + never re-serve
|
||||
// an already-walked image, so the walk keeps moving instead of getting stuck.
|
||||
params: {
|
||||
similar_to: numId, limit: NEIGHBOR_LIMIT, exclude_wip: 1,
|
||||
reach: reach.value,
|
||||
exclude_ids: breadcrumb.value.map((c) => c.id).join(','),
|
||||
},
|
||||
})
|
||||
if (!t.isCurrent()) return
|
||||
neighbors.value = body.images || []
|
||||
@@ -113,6 +123,14 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// Change how far the walk reaches and re-fetch the current anchor's neighbours
|
||||
// with the new setting (the anchor + trail are unchanged — only the grid varies).
|
||||
function setReach (v) {
|
||||
reach.value = Math.max(0, Math.min(1, Number(v)))
|
||||
const id = anchor.value?.id
|
||||
if (id != null) anchorOn(id)
|
||||
}
|
||||
|
||||
// --- TagPanel "host" surface ---------------------------------------------
|
||||
// The anchor IS the current image (same /api/gallery/image/<id> payload the
|
||||
// modal uses), so these mirror the modal store's tag-CRUD, targeting the
|
||||
@@ -189,8 +207,8 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
function close () {}
|
||||
|
||||
return {
|
||||
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT,
|
||||
anchorOn, reset, backTarget, forwardTarget,
|
||||
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT, reach,
|
||||
anchorOn, reset, backTarget, forwardTarget, setReach,
|
||||
// host surface
|
||||
current, currentImageId,
|
||||
reloadTags, addExistingTag, removeTag, createAndAdd, close,
|
||||
|
||||
@@ -39,3 +39,77 @@
|
||||
deliberately not used here. `.fc-muted` is a custom class Vuetify never
|
||||
emits, so no specificity/reorder fight — no !important needed. */
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
|
||||
/* Vuetify 4 dropped its global CSS reset (normalisation moved into each
|
||||
component). FC's layouts assumed the reset zeroed margins on text elements, so
|
||||
restore just that — the "minimal reset" from the v4 upgrade guide — inside
|
||||
Vuetify's own reset layer, which is low precedence so component + app styles
|
||||
still win over it. Batch-4 Vuetify 3→4 (#1449). */
|
||||
@layer vuetify-core.reset {
|
||||
ul, ol, figure, details, summary { padding: 0; margin: 0; }
|
||||
h1, h2, h3, h4, h5, h6, p { margin: 0; }
|
||||
}
|
||||
|
||||
/* Active-tab indicator (operator-flagged 2026-07-13 in the Vuetify-4 review): v4's
|
||||
MD3 v-tab "slider" underline renders wider than the tab and floats below it. The
|
||||
active tab's TEXT is already accent-coloured (color="accent"), so drop the slider
|
||||
and mark the active tab with a subtle accent fill + rounded top — a clean,
|
||||
unambiguous highlight app-wide (Subscriptions / Browse / Settings / Series). */
|
||||
.v-tab__slider { display: none !important; }
|
||||
.v-tab[aria-selected="true"],
|
||||
.v-tab.v-tab--selected {
|
||||
background: rgb(var(--v-theme-accent) / 0.12);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
/* --- Continuous chrome fade (operator-asked 2026-07-13: "group the sub-nav as
|
||||
part of the nav and use a single gradient in them"). ------------------------
|
||||
The TopNav and any sticky sub-header pinned directly beneath it (Gallery's
|
||||
filter bar, the Browse/Series/Settings/Subscriptions tabs bars) used to each
|
||||
paint their OWN dark-to-transparent gradient (or a solid band), so the fade
|
||||
read as happening TWICE — dark, fade out, then dark again. Instead the two
|
||||
share ONE obsidian fade: the nav paints the TOP half (opaque → the seam
|
||||
alpha) and the sub-header paints the CONTINUATION (seam alpha → transparent)
|
||||
over its own height. Both reference --fc-chrome-seam, so the alphas meet
|
||||
exactly at the 64px boundary — no re-darkening, no doubling, one gradient.
|
||||
|
||||
--fc-chrome-seam is the single knob: raise it for a heavier sub-header (more
|
||||
legible tabs/controls over scrolling content), lower it for a lighter fade. */
|
||||
:root {
|
||||
--fc-chrome-rgb: 20, 23, 26; /* obsidian #14171A — matches the TopNav */
|
||||
/* Alpha where the nav hands off to the sub-header — also the "hold" level of
|
||||
the fade. The chrome stays fairly opaque (0.92 → this) through the bulk of
|
||||
its height, then drops to transparent in a small eased section at the very
|
||||
bottom (see the multi-stop gradients), so it reads as a slow falloff that
|
||||
tails off softly rather than a straight line to a hard edge (operator
|
||||
2026-07-13). Raise for heavier/more-legible chrome, lower for a lighter fade. */
|
||||
--fc-chrome-seam: 0.68;
|
||||
/* Actual TopNav height, measured live (ResizeObserver in TopNav.vue) and used
|
||||
by full-height workspaces (Explore/Subscriptions: calc(100vh - var)) and by
|
||||
every sticky sub-header pinned beneath the nav (top: var). This was a
|
||||
hardcoded 64px in ~6 places; Vuetify 4's MD3 sizing made the real nav a
|
||||
different height, so the Explore workspace overflowed and its breadcrumb
|
||||
tucked under the nav (#1481). This fallback is only used pre-measure. */
|
||||
--fc-nav-h: 64px;
|
||||
}
|
||||
/* Applied to a sticky sub-header so it continues the nav's fade instead of
|
||||
restarting it. Percentage stops so the fade always spans the element's height
|
||||
(survives the filter bar's expanding refine panel). The blur keeps tabs and
|
||||
controls legible as the fill thins toward transparent — the solid-surface
|
||||
bars it replaces had none, so it must live here. */
|
||||
.fc-chrome-continues {
|
||||
/* Continues the nav's fade: HOLDS near the seam alpha through the first ~55%
|
||||
(subtle), then eases down to transparent over the last ~45% with an
|
||||
intermediate stop so the tail is soft — no hard line at the bottom edge
|
||||
(operator 2026-07-13). Percentage stops keep the shape spanning the
|
||||
element's height (survives the filter bar's expanding refine panel). */
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--fc-chrome-rgb), var(--fc-chrome-seam)) 0%,
|
||||
rgba(var(--fc-chrome-rgb), 0.60) 55%,
|
||||
rgba(var(--fc-chrome-rgb), 0.28) 82%,
|
||||
rgba(var(--fc-chrome-rgb), 0) 100%
|
||||
);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
switcher and the search stay reachable no matter how far you scroll.
|
||||
Background uses the theme surface token so content scrolls cleanly
|
||||
under it (matches SettingsView's sticky tabs). -->
|
||||
<div class="fc-browse__head">
|
||||
<div class="fc-browse__head fc-chrome-continues">
|
||||
<v-container fluid class="py-0">
|
||||
<!-- Tabs and search share one row: the axis switcher on the left, the
|
||||
search field + active-scope chips on the right (operator-asked
|
||||
@@ -154,9 +154,10 @@ function clearFilter(key) {
|
||||
<style scoped>
|
||||
.fc-browse__head {
|
||||
position: sticky;
|
||||
top: 64px; /* directly under AppShell's 64px sticky TopNav */
|
||||
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
|
||||
z-index: 4;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
/* Background is the shared .fc-chrome-continues fade — it continues the nav's
|
||||
gradient instead of a solid surface band (operator 2026-07-13). */
|
||||
}
|
||||
.fc-browse__bar {
|
||||
display: flex;
|
||||
|
||||
@@ -31,6 +31,20 @@
|
||||
<img :src="c.thumbnail_url" alt="" loading="lazy" />
|
||||
</button>
|
||||
<div class="fc-ex__trail-actions">
|
||||
<!-- Reach (#1476): how far each step reaches past the anchor's cluster.
|
||||
Raise it to break out of a dense signature without hitting Random. -->
|
||||
<div
|
||||
class="fc-ex__reach"
|
||||
title="How far each step reaches — raise it to escape a dense cluster without going fully random"
|
||||
>
|
||||
<v-icon size="16" color="accent">mdi-map-marker-distance</v-icon>
|
||||
<v-slider
|
||||
:model-value="store.reach" @end="store.setReach"
|
||||
:min="0" :max="1" :step="0.2" hide-details density="compact"
|
||||
color="accent" class="fc-ex__reach-slider"
|
||||
/>
|
||||
<span class="fc-muted fc-ex__reach-label">{{ reachLabel }}</span>
|
||||
</div>
|
||||
<!-- Active retrain right where you tag: fold the +/- you just gave
|
||||
into the heads without a trip to Settings (the nightly beat is the
|
||||
passive cadence). -->
|
||||
@@ -153,6 +167,12 @@ const modal = useModalStore()
|
||||
|
||||
const anchorId = computed(() => route.params.imageId || null)
|
||||
const isVideo = computed(() => !!store.anchor?.mime?.startsWith('video/'))
|
||||
const reachLabel = computed(() => {
|
||||
const r = store.reach
|
||||
if (r <= 0.15) return 'Near'
|
||||
if (r <= 0.55) return 'Varied'
|
||||
return 'Far'
|
||||
})
|
||||
|
||||
// #1206: hovering a suggestion in the rail highlights the crop it came from on
|
||||
// the anchor image (same provide/inject as the modal viewer).
|
||||
@@ -266,10 +286,13 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
|
||||
/* Full-height workspace under the sticky top nav. */
|
||||
/* Full-height workspace under the sticky top nav. --fc-nav-h is the nav's REAL
|
||||
measured height (set by TopNav) — a hardcoded 64px here overflowed the
|
||||
viewport under Vuetify 4's taller nav and tucked the breadcrumb under it
|
||||
(#1481). Panes scroll internally, so an exact fit keeps everything on screen. */
|
||||
.fc-ex {
|
||||
display: flex; flex-direction: column;
|
||||
height: calc(100vh - 64px);
|
||||
height: calc(100vh - var(--fc-nav-h, 64px));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -296,6 +319,14 @@ onUnmounted(() => {
|
||||
.fc-ex__trail-actions {
|
||||
margin-left: auto; display: flex; align-items: center; gap: 4px; flex: 0 0 auto;
|
||||
}
|
||||
.fc-ex__reach {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.fc-ex__reach-slider { width: 96px; }
|
||||
.fc-ex__reach-label {
|
||||
font-size: 12px; min-width: 44px; text-align: left;
|
||||
}
|
||||
|
||||
/* The three panes fill the remaining height; each scrolls on its own.
|
||||
grid-template-rows: minmax(0, 1fr) BOUNDS the single row to the container
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
the 64px TopNav (operator-asked 2026-06-12), so the axis switcher and
|
||||
search/sort stay reachable on a long grid. The controls can't sit
|
||||
inside v-window (it clips sticky children), so they're hoisted here. -->
|
||||
<div class="fc-series__head">
|
||||
<div class="fc-series__head fc-chrome-continues">
|
||||
<v-tabs v-model="tab" density="compact">
|
||||
<v-tab value="browse">Browse</v-tab>
|
||||
<v-tab value="suggestions">
|
||||
@@ -294,12 +294,13 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
/* Sticky header (tabs + active-tab controls) pinned under the 64px TopNav, so
|
||||
content scrolls cleanly beneath it. Surface bg matches SettingsView. */
|
||||
content scrolls cleanly beneath it. Background is the shared
|
||||
.fc-chrome-continues fade — continues the nav's gradient rather than a solid
|
||||
band (operator 2026-07-13). */
|
||||
.fc-series__head {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
|
||||
z-index: 4;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.fc-series-browse__controls {
|
||||
|
||||
@@ -7,13 +7,11 @@
|
||||
<!-- Sticky tabs: operator-flagged 2026-05-25 — long Import / Maintenance
|
||||
panels pushed the tab strip out of the viewport, forcing a scroll-
|
||||
to-top just to change tab. AppShell's TopNav is 64px sticky, so the
|
||||
tab strip lives directly under it. Background uses the theme surface
|
||||
token so it visually merges with the page rather than the
|
||||
translucent v-tabs default. -->
|
||||
tab strip lives directly under it. The .fc-chrome-continues fade
|
||||
continues the nav's gradient across the strip (operator 2026-07-13). -->
|
||||
<v-tabs
|
||||
v-model="tab" color="accent" class="mb-4"
|
||||
style="position: sticky; top: 64px; z-index: 4;
|
||||
background: rgb(var(--v-theme-surface));"
|
||||
v-model="tab" color="accent" class="mb-4 fc-chrome-continues"
|
||||
style="position: sticky; top: var(--fc-nav-h, 64px); z-index: 4;"
|
||||
>
|
||||
<v-tab value="overview">Overview</v-tab>
|
||||
<v-tab value="activity">Activity</v-tab>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
align-tabs="start"
|
||||
color="accent"
|
||||
density="compact"
|
||||
class="fc-subs-tabs"
|
||||
class="fc-subs-tabs fc-chrome-continues"
|
||||
>
|
||||
<v-tab value="subscriptions">
|
||||
<v-icon start>mdi-account-multiple-check</v-icon>
|
||||
@@ -55,15 +55,18 @@ const { tab } = useTabQuery(VALID_TABS, 'subscriptions')
|
||||
/* Fixed-height hub: the tabs (and each tab's sticky control bar) stay
|
||||
put while ONLY the tab content scrolls — previously the whole view
|
||||
scrolled instead of just the subscription list (operator-flagged
|
||||
2026-05-28). 64px = the TopNav height (AppShell .fc-content pad-top). */
|
||||
height: calc(100vh - 64px);
|
||||
2026-05-28). --fc-nav-h = the TopNav's real measured height (#1481). */
|
||||
height: calc(100vh - var(--fc-nav-h, 64px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-subs-tabs {
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
|
||||
/* Cancel the shell's pt-2 so the tabs sit flush under the 64px nav, letting
|
||||
the .fc-chrome-continues fade read as one gradient with it (operator
|
||||
2026-07-13). The fade replaces the old border-bottom separator. */
|
||||
margin-top: -8px;
|
||||
}
|
||||
.fc-subs-window {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Unit tests for ExtensionService._derive — the URL → (platform, slug)
|
||||
parser that gates the browser extension's "Add as source" button and pulls
|
||||
the creator slug on probe/add.
|
||||
|
||||
Regression cover for #1485: Patreon serves the same creator under three URL
|
||||
shapes — bare `patreon.com/Atole`, `c/`, and `cw/` (the "creator workspace"
|
||||
URL you land on once SUBSCRIBED). The button used to vanish while subscribed
|
||||
because the pattern only matched the bare root and excluded `c/`.
|
||||
|
||||
_derive is pure URL parsing (no DB / no async), so a session-less instance is
|
||||
fine to exercise directly.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.extension_service import (
|
||||
ExtensionService,
|
||||
InvalidUrlError,
|
||||
UnknownPlatformError,
|
||||
)
|
||||
|
||||
_svc = ExtensionService(None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, slug",
|
||||
[
|
||||
# All three Patreon creator prefixes resolve to the same vanity slug.
|
||||
("https://www.patreon.com/Atole", "Atole"),
|
||||
("https://www.patreon.com/c/Atole", "Atole"),
|
||||
("https://www.patreon.com/cw/Atole", "Atole"), # subscribed-view URL
|
||||
# A creator's inner page still derives the slug (trailing sub-path).
|
||||
("https://www.patreon.com/cw/Atole/posts", "Atole"),
|
||||
("https://www.patreon.com/Atole/membership", "Atole"),
|
||||
("https://patreon.com/c/Atole", "Atole"), # bare host, no www
|
||||
],
|
||||
)
|
||||
def test_derive_patreon_creator_urls(url, slug):
|
||||
platform, got = _svc._derive(url)
|
||||
assert platform == "patreon"
|
||||
assert got == slug
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
# Patreon's own nav pages must never read as a creator slug.
|
||||
"https://www.patreon.com/home",
|
||||
"https://www.patreon.com/settings",
|
||||
"https://www.patreon.com/search",
|
||||
"https://www.patreon.com/messages",
|
||||
"https://www.patreon.com/library",
|
||||
"https://www.patreon.com/notifications",
|
||||
"https://www.patreon.com/posts/12345", # post permalink
|
||||
"https://www.patreon.com/settings/billing", # nav sub-page
|
||||
],
|
||||
)
|
||||
def test_derive_patreon_nav_pages_rejected(url):
|
||||
with pytest.raises(UnknownPlatformError):
|
||||
_svc._derive(url)
|
||||
|
||||
|
||||
def test_derive_rejects_missing_scheme():
|
||||
with pytest.raises(InvalidUrlError):
|
||||
_svc._derive("patreon.com/Atole")
|
||||
@@ -82,24 +82,28 @@ async def _system_tag(db, name):
|
||||
|
||||
@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 (chrome) is hidden from the default gallery; wip AND editor screenshot
|
||||
# (the PROCESS system tags) are NOT — they're real art / process shots that stay
|
||||
# visible (milestone 141 + #1464). Seeded system tags survive the harness TRUNCATE.
|
||||
imgs = await _seed_images(db, 4, sha_prefix="p")
|
||||
banner = await _system_tag(db, "banner")
|
||||
wip = await _system_tag(db, "wip")
|
||||
editor = await _system_tag(db, "editor screenshot")
|
||||
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.execute(image_tag.insert().values(
|
||||
image_record_id=imgs[3].id, tag_id=editor.id, source="manual"))
|
||||
await db.flush()
|
||||
svc = GalleryService(db)
|
||||
|
||||
# Default: the banner image is hidden; the wip image + the plain image stay.
|
||||
# Default: only the banner image is hidden; wip + editor + plain all 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
|
||||
assert imgs[3].id in default_ids # editor screenshot visible (#1464)
|
||||
|
||||
# include_hidden surfaces the banner image (the Hidden view).
|
||||
shown = {i.id for i in (
|
||||
|
||||
@@ -170,6 +170,23 @@ async def test_similar_respects_limit(db):
|
||||
assert len(res) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_similar_exclude_ids_drops_walked(db):
|
||||
"""Explore passes its breadcrumb as exclude_ids so already-walked images
|
||||
aren't re-served as neighbours (#1476). reach>0 runs cleanly too (small pool
|
||||
→ the sampler passes through)."""
|
||||
src = await _img(db, 1, _vec(1, 0))
|
||||
walked = await _img(db, 2, _vec(1, 0.05))
|
||||
fresh = await _img(db, 3, _vec(1, 0.3))
|
||||
svc = GalleryService(db)
|
||||
res = await svc.similar(src.id, limit=10, exclude_ids=[walked.id])
|
||||
ids = {i.id for i in res}
|
||||
assert walked.id not in ids
|
||||
assert fresh.id in ids
|
||||
res_reach = await svc.similar(src.id, limit=10, reach=1.0)
|
||||
assert fresh.id in {i.id for i in res_reach}
|
||||
|
||||
|
||||
# --- API ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -15,7 +15,7 @@ from backend.app.models import (
|
||||
from backend.app.models.tag import image_tag
|
||||
from backend.app.services.ml.heads import (
|
||||
auto_apply_sweep,
|
||||
presentation_auto_apply_sweep,
|
||||
system_tag_auto_apply_sweep,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
@@ -70,7 +70,7 @@ def test_presentation_sweep_hides_chrome(db_sync):
|
||||
_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)
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
|
||||
assert res["n_applied"] == 1
|
||||
assert _source(db_sync, img.id, banner.id) == "presentation_auto"
|
||||
|
||||
@@ -86,7 +86,7 @@ def test_presentation_sweep_hard_skips_valued_image(db_sync):
|
||||
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)
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
|
||||
assert res["n_applied"] == 0
|
||||
assert _source(db_sync, img.id, banner.id) is None
|
||||
|
||||
@@ -102,7 +102,7 @@ def test_presentation_sweep_flags_conflict(db_sync):
|
||||
_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)
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
|
||||
assert res["n_applied"] == 1
|
||||
assert res["n_flagged"] == 1
|
||||
assert _source(db_sync, img.id, banner.id) == "presentation_auto"
|
||||
@@ -123,7 +123,7 @@ def test_presentation_sweep_disabled_is_noop(db_sync):
|
||||
_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)
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
|
||||
assert res["n_applied"] == 0
|
||||
assert _source(db_sync, img.id, banner.id) is None
|
||||
|
||||
@@ -134,7 +134,7 @@ def test_presentation_sweep_ignores_wip(db_sync):
|
||||
_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)
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
|
||||
assert res["n_applied"] == 0
|
||||
assert _source(db_sync, img.id, wip.id) is None
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Process-group auto-apply sweep (#1464): wip / editor screenshot auto-tag at a
|
||||
flat threshold with a PROVISIONAL source (`process_auto`) so the head never trains
|
||||
on its own output, and stay VISIBLE (unlike chrome). Mirrors the chrome guards.
|
||||
numpy-only (no sklearn), tested directly via the sync session."""
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models import (
|
||||
ImageRecord,
|
||||
MLSettings,
|
||||
PresentationReview,
|
||||
Tag,
|
||||
TagHead,
|
||||
TagKind,
|
||||
)
|
||||
from backend.app.models.tag import image_tag
|
||||
from backend.app.services.ml.heads import system_tag_auto_apply_sweep
|
||||
from backend.app.services.ml.training_data import _ids_with_tag
|
||||
|
||||
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):
|
||||
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 _enable_process(db):
|
||||
# process auto-apply is opt-in (default False) — turn it on for these tests.
|
||||
db.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one().process_auto_apply_enabled = True
|
||||
|
||||
|
||||
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_process_sweep_applies_wip_and_editor(db_sync):
|
||||
_enable_process(db_sync)
|
||||
wip = _system_tag(db_sync, "wip")
|
||||
editor = _system_tag(db_sync, "editor screenshot")
|
||||
_head(db_sync, wip.id, 0, weight=3.0)
|
||||
_head(db_sync, editor.id, 1, weight=3.0)
|
||||
w_img = _img(db_sync, "a" * 64, _emb(0))
|
||||
e_img = _img(db_sync, "b" * 64, _emb(1))
|
||||
db_sync.commit()
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="process")
|
||||
assert res["n_applied"] == 2
|
||||
assert _source(db_sync, w_img.id, wip.id) == "process_auto"
|
||||
assert _source(db_sync, e_img.id, editor.id) == "process_auto"
|
||||
|
||||
|
||||
def test_process_sweep_disabled_by_default_is_noop(db_sync):
|
||||
# process_auto_apply_enabled defaults False (opt-in) — no enable = no-op.
|
||||
wip = _system_tag(db_sync, "wip")
|
||||
_head(db_sync, wip.id, 0, weight=3.0)
|
||||
img = _img(db_sync, "c" * 64, _emb(0))
|
||||
db_sync.commit()
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="process")
|
||||
assert res["n_applied"] == 0
|
||||
assert _source(db_sync, img.id, wip.id) is None
|
||||
|
||||
|
||||
def test_process_sweep_skips_valued_image(db_sync):
|
||||
# Guard 1: never auto-apply to an image the operator already content-tagged.
|
||||
_enable_process(db_sync)
|
||||
wip = _system_tag(db_sync, "wip")
|
||||
_head(db_sync, wip.id, 0, weight=3.0)
|
||||
content = Tag(name="mychar", kind=TagKind.character)
|
||||
db_sync.add(content)
|
||||
db_sync.flush()
|
||||
img = _img(db_sync, "d" * 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 = system_tag_auto_apply_sweep(db_sync, mode="process")
|
||||
assert res["n_applied"] == 0
|
||||
assert _source(db_sync, img.id, wip.id) is None
|
||||
|
||||
|
||||
def test_process_sweep_flags_conflict_with_process_mode(db_sync):
|
||||
# Guard 2: also scores high on a content head → still applied, but flagged
|
||||
# for review with mode='process' (the ring-loud guard).
|
||||
_enable_process(db_sync)
|
||||
wip = _system_tag(db_sync, "wip")
|
||||
_head(db_sync, wip.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)
|
||||
img = _img(db_sync, "e" * 64, _emb(0))
|
||||
db_sync.commit()
|
||||
res = system_tag_auto_apply_sweep(db_sync, mode="process")
|
||||
assert res["n_applied"] == 1
|
||||
assert res["n_flagged"] == 1
|
||||
flag = db_sync.execute(
|
||||
select(PresentationReview).where(
|
||||
PresentationReview.image_record_id == img.id,
|
||||
PresentationReview.tag_id == wip.id,
|
||||
)
|
||||
).scalar_one()
|
||||
assert flag.mode == "process"
|
||||
assert flag.conflict_tag_id == content.id
|
||||
|
||||
|
||||
def test_process_auto_source_never_trains_head(db_sync):
|
||||
# The runaway break: provisional wip tags (process sweep 'process_auto', soft
|
||||
# title 'wip_title_soft') are NOT training positives; a HARD title-heuristic /
|
||||
# manual one IS. So the head learns only from trusted labels, never its own
|
||||
# output or the low-precision sketch/doodle tier (#1464 + #1474).
|
||||
wip = _system_tag(db_sync, "wip")
|
||||
auto_img = _img(db_sync, "f" * 64, _emb(0))
|
||||
soft_img = _img(db_sync, "9" * 64, _emb(2))
|
||||
title_img = _img(db_sync, "0" * 64, _emb(1))
|
||||
db_sync.execute(image_tag.insert().values(
|
||||
image_record_id=auto_img.id, tag_id=wip.id, source="process_auto"))
|
||||
db_sync.execute(image_tag.insert().values(
|
||||
image_record_id=soft_img.id, tag_id=wip.id, source="wip_title_soft"))
|
||||
db_sync.execute(image_tag.insert().values(
|
||||
image_record_id=title_img.id, tag_id=wip.id, source="wip_title"))
|
||||
db_sync.commit()
|
||||
positives = set(_ids_with_tag(db_sync, wip.id))
|
||||
assert title_img.id in positives # trusted HARD label trains the head
|
||||
assert auto_img.id not in positives # its own auto-applied output does NOT
|
||||
assert soft_img.id not in positives # low-precision soft tier does NOT
|
||||
|
||||
|
||||
def test_soft_wip_conflict_audit_flags_ring_loud(db_sync):
|
||||
# A soft-tagged image (sketch/doodle title) that ALSO scores high on a content
|
||||
# head is probably finished art mis-tagged — flagged for review; a quiet one is not.
|
||||
from backend.app.services.ml.heads import soft_wip_conflict_audit
|
||||
|
||||
s = db_sync.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one()
|
||||
s.process_conflict_threshold = 0.6
|
||||
wip = _system_tag(db_sync, "wip")
|
||||
content = Tag(name="looksreal", kind=TagKind.general)
|
||||
db_sync.add(content)
|
||||
db_sync.flush()
|
||||
_head(db_sync, content.id, 0, weight=1.0) # sigmoid(1)=0.73 > 0.6 conflict
|
||||
ring = _img(db_sync, "1" * 64, _emb(0)) # scores on the content head
|
||||
quiet = _img(db_sync, "2" * 64, _emb(5)) # orthogonal → 0.5 < 0.6
|
||||
for img in (ring, quiet):
|
||||
db_sync.execute(image_tag.insert().values(
|
||||
image_record_id=img.id, tag_id=wip.id, source="wip_title_soft"))
|
||||
db_sync.commit()
|
||||
|
||||
res = soft_wip_conflict_audit(db_sync)
|
||||
assert res["n_flagged"] == 1
|
||||
flag = db_sync.execute(
|
||||
select(PresentationReview).where(PresentationReview.image_record_id == ring.id)
|
||||
).scalar_one()
|
||||
assert flag.mode == "process"
|
||||
assert flag.conflict_tag_id == content.id
|
||||
assert db_sync.execute(
|
||||
select(PresentationReview).where(PresentationReview.image_record_id == quiet.id)
|
||||
).scalar_one_or_none() is None
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Explore reach sampler (#1476) — pure unit tests for `_reach_sample`, which picks
|
||||
a distance-rank spread so the neighbour pool handed to MMR spans near→mid-far and
|
||||
the walk can escape a dense cluster. No DB. `_reach_sample` only indexes rows, so
|
||||
plain ints stand in for the (ImageRecord, ...) tuples."""
|
||||
from backend.app.services.gallery_service import _reach_sample
|
||||
|
||||
|
||||
def test_reach_zero_or_negative_passes_through():
|
||||
rows = list(range(1000))
|
||||
assert _reach_sample(rows, 40, 0.0) is rows
|
||||
assert _reach_sample(rows, 40, -1.0) is rows
|
||||
|
||||
|
||||
def test_small_pool_passes_through():
|
||||
# n <= want (limit*8 = 320) → nothing to reach into.
|
||||
rows = list(range(50))
|
||||
assert _reach_sample(rows, 40, 1.0) is rows
|
||||
|
||||
|
||||
def test_higher_reach_reaches_deeper_ranks():
|
||||
rows = list(range(1000))
|
||||
near = _reach_sample(rows, 40, 0.2)
|
||||
far = _reach_sample(rows, 40, 1.0)
|
||||
# Both keep the nearest rank (stride starts at 0) so you can still tag the cluster.
|
||||
assert near[0] == 0
|
||||
assert far[0] == 0
|
||||
# But higher reach samples genuinely farther ranks.
|
||||
assert max(far) > max(near)
|
||||
assert max(far) >= 900 # reach=1 spans (almost) the whole pool
|
||||
assert max(near) <= 550 # reach=0.2 stays in the near half
|
||||
# Never runs past the pool.
|
||||
assert max(far) <= len(rows) - 1
|
||||
+28
-1
@@ -8,7 +8,7 @@ negative cases (substrings like ``swipe`` / ``wiped``) are the load-bearing ones
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from backend.app.services.wip_title import matches_wip_title
|
||||
from backend.app.services.wip_title import matches_soft_wip_title, matches_wip_title
|
||||
|
||||
|
||||
@pytest.mark.parametrize("title", [
|
||||
@@ -44,6 +44,33 @@ def test_matches_positive(title):
|
||||
"finished at last",
|
||||
"Kawips diner", # 'wip' mid-word
|
||||
"swipright",
|
||||
"quick sketch of Nami", # soft cue — NOT a HARD WIP match
|
||||
])
|
||||
def test_matches_negative(title):
|
||||
assert matches_wip_title(title) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("title", [
|
||||
"quick sketch",
|
||||
"Nami sketch",
|
||||
"morning doodle",
|
||||
"some doodles",
|
||||
"sketches from today",
|
||||
"a little scribble",
|
||||
"SKETCH",
|
||||
])
|
||||
def test_soft_matches_positive(title):
|
||||
assert matches_soft_wip_title(title) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("title", [
|
||||
None,
|
||||
"",
|
||||
"sketchbook tour", # 'sketch' inside sketchbook — must NOT match
|
||||
"kadoodle mascot", # 'doodle' mid-word
|
||||
"the final piece",
|
||||
"WIP", # a HARD cue is not a SOFT cue
|
||||
"prescribed colours", # 'scrib' inside prescribed — must NOT match
|
||||
])
|
||||
def test_soft_matches_negative(title):
|
||||
assert matches_soft_wip_title(title) is False
|
||||
|
||||
@@ -9,7 +9,11 @@ from sqlalchemy import select
|
||||
from backend.app.celery_app import celery
|
||||
from backend.app.models import Artist, ImageProvenance, ImageRecord, Post, Source
|
||||
from backend.app.models.tag import image_tag
|
||||
from backend.app.services.wip_title import apply_wip_image_tags, resolve_wip_tag_id
|
||||
from backend.app.services.wip_title import (
|
||||
WIP_TITLE_SOFT_SOURCE,
|
||||
apply_wip_image_tags,
|
||||
resolve_wip_tag_id,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@@ -104,3 +108,14 @@ def test_backfill_tags_only_wip_titled_posts(db_sync):
|
||||
|
||||
# Idempotent: a second sweep finds the tag already present and applies nothing.
|
||||
assert backfill_wip_title_tags.apply().get() == 0
|
||||
|
||||
|
||||
def test_apply_soft_source_stamps_wip_title_soft(db_sync):
|
||||
# The soft tier (#1474) stamps a distinct provisional source.
|
||||
tag_id = resolve_wip_tag_id(db_sync)
|
||||
rec = _img(db_sync)
|
||||
db_sync.commit()
|
||||
assert apply_wip_image_tags(
|
||||
db_sync, [rec.id], tag_id, source=WIP_TITLE_SOFT_SOURCE
|
||||
) == 1
|
||||
assert _wip_source(db_sync, rec.id, tag_id) == "wip_title_soft"
|
||||
|
||||
Reference in New Issue
Block a user