Compare commits

...
21 Commits
Author SHA1 Message Date
bvandeusen 89c83ee5de Merge pull request 'fix(extension): Add-to-FC button on subscribed Patreon creators + v1.0.8 (#1485)' (#230) from dev into main
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
extension / lint (push) Successful in 8s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m46s
Build images / sign-extension (push) Successful in 5m8s
Build images / build-web (push) Successful in 10s
2026-07-13 17:56:32 -04:00
bvandeusenandClaude Opus 4.8 69b5637bd6 fix(extension): show Add-to-FC button on subscribed Patreon creators (#1485)
CI / lint (push) Successful in 3s
extension / lint (push) Successful in 11s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m47s
extension / lint (pull_request) Successful in 9s
Symptom (operator-flagged): the extension injected the Add-as-source button on
a Patreon creator you had NOT subscribed to, but it disappeared once you were
subscribed — the opposite of when it's useful.

Root cause (extension logic, not Patreon security): Patreon serves a creator
under three URL shapes — bare patreon.com/Atole, patreon.com/c/Atole, and
patreon.com/cw/Atole (the 'creator workspace' URL you land on once subscribed;
documented in patreon_resolver._VANITY_RE). The button's artist-page gate
(PLATFORM_ARTIST_PATTERNS.patreon in platforms.js) and its byte-mirror probe
pattern (_PLATFORM_PATTERNS in extension_service._derive) only matched the bare
single-segment form and explicitly excluded c/. So the subscribed-view URL
failed the gate → no button. The ingestion resolver already handled all three;
only these two gates were too narrow.

Fix: both regexes now accept optional cw/ and c/ prefixes and drop the strict
single-segment end-anchor, so a creator's inner page (/cw/Atole/posts,
/Atole/membership) also matches — robust to whatever exact shape the subscribed
view uses. Nav-page exclusions (home/search/messages/notifications/library/
settings/posts + post permalinks) preserved. New unit test covers all three
prefixes, sub-paths, and nav-page rejection (both regexes validated identically).

Bump extension 1.0.7→1.0.8 so a fresh signed XPI ships the fix (also exercises
batch-5 web-ext-10's AMO sign path end-to-end on the main build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:50:07 -04:00
bvandeusen d3192f1843 Merge pull request 'chore(deps): web-ext 8→10 (Renovate batch 5) (#1450)' (#229) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
extension / lint (push) Successful in 9s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m48s
2026-07-13 16:36:26 -04:00
bvandeusenandClaude Opus 4.8 51749e05db chore(deps): update web-ext 8→10 (batch 5) (#1450)
CI / lint (push) Successful in 2s
extension / lint (push) Successful in 9s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m58s
extension / lint (pull_request) Successful in 9s
Renovate dep-dashboard batch 5. web-ext is the extension's build/lint/sign
CLI (devDependency only; no extension source changes).

Verified against the 8→10 changelog + FC's actual usage:
- All CLI flags we use survive unchanged: --source-dir, --no-config-discovery,
  --ignore-files, --overwrite-dest (build), --channel/--api-key/--api-secret
  (sign). No removed/renamed flags for lint/build/sign.
- v9's one breaking change (.js config files rejected) does NOT apply: we pass
  --no-config-discovery on every command and ship no config file.
- Node: web-ext 10 baselines Node 22. The lint job runs on node:24-bookworm-slim;
  the load-bearing AMO sign job runs on ci-python:3.14 which installs Node 24
  (CI-runner NODE_MAJOR=24) — both satisfy it. Sign is cache-skipped this push
  (extension version unchanged at 1.0.7) but is verified compatible for the next
  version bump.
- The bundled addons-linter jumps to 10.1.0 — the extension.yml lint job (web-ext
  lint over the MV3 manifest) is the CI verifier for any new manifest findings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:30:11 -04:00
bvandeusen 5a5694f200 Merge pull request 'fix(ui): softer chrome-gradient falloff + segmented media toggle (#1478)' (#228) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m42s
2026-07-13 15:55:02 -04:00
bvandeusenandClaude Opus 4.8 50d6c42207 fix(ui): softer chrome-gradient falloff + segmented media toggle (#1478)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m49s
Two operator-flagged polish items from the Vuetify-4 review:

Gradient: reshape the nav + sub-header fade from a near-linear ramp to a
hold-then-soft-drop profile — hold high opacity (0.92 → seam 0.68) through the
bulk of the chrome, then ease to transparent over a small section at the bottom
with an intermediate stop, so it tails off softly instead of running a straight
line into a hard edge. Raising the shared --fc-chrome-seam also makes the tab
strips more legible over scrolling content.

Media toggle: FC's global VBtn { rounded: 'pill' } default made Vuetify 4
pill-round each SEGMENT of the All/Images/Videos v-btn-toggle individually, so
the rounded ends collided at the joins. Square the inner segments and clip the
group to one 8px outline — a proper segmented control.

Both are colour/border-radius only — no control height changes, so the filter
bar height and the nav offset (--fc-nav-h) are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:50:33 -04:00
bvandeusen bb1a938cc0 Merge pull request 'fix(ui): measure real nav height (--fc-nav-h) — Explore fits on 1080p, no breadcrumb tuck (#1481)' (#227) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m50s
2026-07-13 15:32:25 -04:00
bvandeusenandClaude Opus 4.8 67c7ca8603 fix(ui): measure real nav height (--fc-nav-h), stop Explore breadcrumb tucking under nav (#1481)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m44s
The app uses a plain sticky TopNav (no v-main), and the nav's height was
hardcoded as 64px in ~6 places: the Explore + Subscriptions full-height
workspaces (height: calc(100vh - 64px)) and every sticky sub-header pinned
beneath the nav (top: 64px — Gallery filter bar, Browse/Series/Settings tabs).

Vuetify 4's MD3 sizing changed the real nav height, so 64px was wrong: the
Explore workspace was sized taller than the space below the nav, overflowed the
viewport, and its breadcrumb tucked under the (taller) nav on 1080p.

TopNav now measures its own height via ResizeObserver and publishes it as
--fc-nav-h on documentElement (default 64px in app.css). Every consumer uses
var(--fc-nav-h) instead of the magic number, so the layout self-corrects to the
nav's real height and stays correct as it reflows (per-view teleported actions,
mobile breakpoint). Also tightens the new chrome-gradient seam — sub-headers now
pin at the nav's exact bottom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:27:37 -04:00
bvandeusen fc0293029d Merge pull request 'fix(ui): Vuetify-4 review polish — active-tab indicator + one continuous chrome gradient' (#226) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m44s
2026-07-13 14:47:59 -04:00
bvandeusenandClaude Opus 4.8 eed42a260a fix(ui): one continuous chrome gradient across nav + sticky sub-headers (#1478)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 44s
CI / integration (push) Successful in 3m50s
The TopNav and each sticky sub-header pinned beneath it (Gallery's filter bar,
the Browse/Series/Settings/Subscriptions tabs bars) each painted their OWN
dark-to-transparent gradient (Gallery) or a solid surface band (the rest), so
the fade read as happening twice — dark, fade out, then dark again — instead of
one gradient flowing from the nav down through the sub-nav.

Operator asked to treat the sub-nav as part of the nav with a single gradient.
New shared .fc-chrome-continues primitive (app.css): the nav fades from opaque
to a shared --fc-chrome-seam alpha (on views flagged meta.stickyChrome), and the
sub-header continues from that exact seam alpha to transparent over its own
height. Both reference the same var so the alphas meet at the 64px boundary — no
re-darkening, no doubling. Percentage stops keep it spanning the filter bar's
expanding refine panel; the primitive's blur keeps tabs/controls legible where
the old solid bars had none. --fc-chrome-seam is the single tuning knob.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:42:07 -04:00
bvandeusenandClaude Opus 4.8 61b14e8f65 fix(ui): cleaner active-tab indicator (drop odd v4 slider) (#1480)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m53s
Vuetify 4's MD3 v-tab slider underline rendered wider than the tab and floated
below it (operator-flagged in the v4 review). The active tab's text is already
accent-coloured, so drop the slider and mark the active tab with a subtle accent
fill + rounded top — a clean highlight, app-wide across all tabbed views.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:27:02 -04:00
bvandeusen 7d1c701b67 Merge pull request 'fix(ui): remove redundant app-shell top gap (#1480)' (#225) from dev into main
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m48s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 7s
2026-07-13 14:12:41 -04:00
bvandeusenandClaude Opus 4.8 447bf73519 fix(ui): remove redundant app-shell top gap (sticky nav double-offset)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m44s
Every view showed a large empty band at the top (operator-flagged during the
Vuetify-4 review; pre-existing). Cause: .fc-content had padding-top:64px to clear
the navbar, but TopNav is position:sticky and already reserves its own space in
the v-app flex column — the 64px was a fixed-navbar leftover that double-counted
the offset. Removed it; content now flows directly below the sticky nav (and the
full-height calc(100vh-64px) views no longer overflow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:09:15 -04:00
bvandeusen b638382cd5 Merge pull request 'Ship: UI framework batch 4 — Vuetify 4 + vue-router 5 + pinia 3 (#1449)' (#224) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 28s
CI / frontend-build (push) Successful in 34s
CI / backend-lint-and-test (push) Successful in 53s
CI / integration (push) Successful in 3m46s
2026-07-13 13:40:25 -04:00
bvandeusenandClaude Opus 4.8 6104452d2e feat(deps): vuetify 3→4 (batch 4 phase B, #1449)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m49s
Vuetify 4 is a Material-Design-3 styling refresh with revert snippets, not a
component-API overhaul. FC's exposure was small:

- Bump vuetify ^4, vite-plugin-vuetify ^2.1.0, vue ^3.5, engines node>=24.
- Restore the dropped global CSS reset (minimal reset from the upgrade guide) in
  Vuetify's own low-precedence reset layer, so FC's margin-zeroing assumptions hold.
- v-row prop→utility: 'dense' → density=compact (×3), align=center → class=align-center.
- v-snackbar: multi-line removed → min-height=68.
- v-autocomplete #item slot: item→internalItem (item now aliases raw) in TagPicker
  + GalleryFilterBar (item.raw.* → internalItem.raw.*).

ACCEPTED (cosmetic, operator reviews live per plan #158): MD3 typography
(text-body-2 ×73), non-uppercase buttons (v4 dropped the uppercase default),
MD3 elevation. CI verifies BUILD only — the LOOK is the live-review pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:35:01 -04:00
bvandeusenandClaude Opus 4.8 b59828635e chore(deps): pinia 2→3 + vue-router 4→5 (batch 4 phase A, #1449)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 4m9s
Both are Vue-3-compatible majors with no code impact for FC:
- vue-router 5: no breaking changes when not using file-based routing (FC uses a
  plain createRouter in router.js).
- pinia 3: drops Vue 2 + deprecated APIs; FC uses string-first setup-syntax
  defineStore + no custom pinia plugins, so nothing to change.
Phase A of the Vuetify-4 UI framework migration (milestone #158); Vuetify 4 lands
in phase B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:25:16 -04:00
bvandeusen 17903068b4 Merge pull request 'Ship: system-tag refactor + soft WIP tier + Explore reach (#1464, #1474, #1476)' (#223) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m45s
2026-07-13 12:13:19 -04:00
bvandeusenandClaude Opus 4.8 fac5ae6ce5 feat(explore): reach dial to escape dense clusters + anti-revisit (#1476)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m50s
The Explore walk got stuck in dense signatures — neighbours all too similar, so
forward-arrow couldn't escape and Random was the only exit. Root cause: MMR only
diversifies WITHIN the nearest ~400 pool; in a dense cluster that whole pool is
near-identical, so there's no escape route in it.

- gallery_service.similar(reach=0.0, exclude_ids=None): reach>0 widens the pool
  (cap 400→1000) and _reach_sample strides across an outward-growing distance span
  so the set handed to MMR spans near→mid-far (guaranteed escape routes), not just
  the tight cluster. exclude_ids drops already-walked images. Gallery 'more like
  this' (reach=0) is unchanged.
- api/gallery similar: parse reach + exclude_ids.
- explore store: default reach 0.4 (auto-diversifies without touching the dial),
  pass the breadcrumb as exclude_ids, setReach action.
- ExploreView: a Near↔Far reach slider in the trail.
- tests: _reach_sample math (deeper ranks with higher reach, near kept); similar
  exclude_ids drops walked + reach path runs clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:06:22 -04:00
bvandeusenandClaude Opus 4.8 af0d39ed52 feat(wip): soft title tier — sketch/doodle vocab + ring-loud audit (#1474)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m53s
Extends WIP title-tagging to lower-precision cues (sketch/doodle/scribble) safely.

- wip_title.py: soft matcher (word-anchored; sketchbook/kadoodle don't trip it);
  WIP_TITLE_SOFT_SOURCE + soft SQL prefilter; apply_wip_image_tags takes a source arg.
- training_data._AUTO_SOURCES += 'wip_title_soft' → the soft tier is PROVISIONAL and
  never trains the wip head (a finished "sketch" can't pollute it). Only the hard
  tier (wip_title) + manual train.
- ImportSettings.wip_soft_title_tagging_enabled (OFF by default, opt-in). Migration 0087.
- importer: hard tier wins, soft is the fallback (source wip_title_soft).
- backfill: refactored into a shared _backfill_wip_tier; hard always, soft when enabled.
- heads.soft_wip_conflict_audit + daily beat: score soft-tagged images against content
  heads, flag ring-loud ones (PresentationReview mode=process) for the review strip —
  the operator's "measure if they got falsely tagged" safety.
- api settings toggle; ImportFiltersForm soft toggle.
- tests: soft matcher pos/neg; soft source not a training positive; audit flags
  ring-loud + spares quiet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 10:14:38 -04:00
bvandeusenandClaude Opus 4.8 d9a14e890d feat(system-tags): process auto-tag settings UI + mode-aware review strip (#1464)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m44s
Frontend for the system-tag refactor (milestone #157 step 6).

- HeadsCard: new 'Auto-tag work-in-progress' section (enable + tag-confidence +
  conflict knobs) for wip/editor process auto-apply, mirroring the chrome card;
  copy notes they stay VISIBLE and the head only learns from titles/manual (no
  runaway). Presentation copy narrowed to banner-only.
- HiddenReviewStrip: mode-aware — chrome flags read 'hidden as X / Keep hidden /
  Un-hide'; process flags read 'auto-tagged X / Keep tag / Remove tag'. Same
  endpoints (the backend returns mode), different words.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:22:13 -04:00
bvandeusenandClaude Opus 4.8 ad2a5fc5fe feat(system-tags): process vs chrome groups + WIP provisional auto-apply (#1464)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 39s
CI / integration (push) Successful in 3m48s
Backend for the system-tag behavior refactor (milestone #157). editor screenshot
moves from chrome (hidden) to the PROCESS group (shown, like wip); wip+editor gain
provisional auto-apply so they stop needing endless manual identification —
without a runaway loop.

- tag.py: split PRESENTATION_SYSTEM_TAGS → CHROME_SYSTEM_TAGS (banner) +
  PROCESS_SYSTEM_TAGS (wip, editor screenshot).
- heads.py: generalize presentation_auto_apply_sweep → system_tag_auto_apply_sweep
  (mode chrome|process). Same Guard 1 (skip human/confirmed) + Guard 2 (ring-loud
  conflict → PresentationReview). process mode uses source 'process_auto' and does
  NOT hide (hide is a gallery-query effect of group membership).
- training_data._AUTO_SOURCES += 'process_auto' → the head never trains on its own
  auto-applied output; only wip_title/manual train it (the runaway break).
- ml_settings: process_auto_apply_enabled (OFF, opt-in) + threshold + conflict
  threshold. presentation_review.mode ('chrome'|'process'). Migration 0086.
- gallery_service: default-hide reads CHROME only (editor now shows); Explore
  neighbors exclude the whole PROCESS group.
- tasks/ml + celery beat: scheduled_process_auto_apply (daily, opt-in); prune
  covers both modes.
- api: ml_admin process_* CRUD+validation; hidden-review returns mode.
- tests: rename chrome sweep calls; new test_process_auto_apply (apply, guards,
  mode flag, no-self-train); gallery test asserts editor now visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:15:59 -04:00
49 changed files with 1280 additions and 229 deletions
@@ -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")
+19 -3
View File
@@ -148,6 +148,17 @@ async def similar():
# Explore passes exclude_wip=1 to also drop work-in-progress from the # 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). # rabbit-hole; the gallery's own "similar" button omits it (keeps wip, #1274).
exclude_wip = request.args.get("exclude_wip") in ("1", "true", "True") 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. # post_id is the exclusive post-detail view — not a similarity scope.
# include_hidden is a gallery-browse flag; similar() has its OWN presentation # include_hidden is a gallery-browse flag; similar() has its OWN presentation
# exclusion (a similarity-quality concern, #1274), so drop it here (#141). # exclusion (a similarity-quality concern, #1274), so drop it here (#141).
@@ -158,7 +169,8 @@ async def similar():
svc = GalleryService(session) svc = GalleryService(session)
try: try:
images = await svc.similar( 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: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
if images is None: if images is None:
@@ -236,8 +248,10 @@ async def jump():
# content", surfaced in the gallery's Show-hidden review strip. ----------- # content", surfaced in the gallery's Show-hidden review strip. -----------
@gallery_bp.route("/hidden-review", methods=["GET"]) @gallery_bp.route("/hidden-review", methods=["GET"])
async def hidden_review(): async def hidden_review():
"""Unresolved presentation auto-hide flags, most-concerning first (highest """Unresolved system-tag auto-apply review flags (chrome + process, #1464),
content score) — for the gallery's Hidden-view review strip.""" 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) ptag = aliased(Tag)
ctag = aliased(Tag) ctag = aliased(Tag)
async with get_session() as session: async with get_session() as session:
@@ -247,6 +261,7 @@ async def hidden_review():
PresentationReview.tag_id, PresentationReview.tag_id,
PresentationReview.conflict_tag_id, PresentationReview.conflict_tag_id,
PresentationReview.conflict_score, PresentationReview.conflict_score,
PresentationReview.mode,
ImageRecord.path, ImageRecord.thumbnail_path, ImageRecord.path, ImageRecord.thumbnail_path,
ImageRecord.sha256, ImageRecord.mime, ImageRecord.sha256, ImageRecord.mime,
ptag.name.label("tag_name"), ptag.name.label("tag_name"),
@@ -266,6 +281,7 @@ async def hidden_review():
"conflict_tag_id": r.conflict_tag_id, "conflict_tag_id": r.conflict_tag_id,
"conflict_name": r.conflict_name, "conflict_name": r.conflict_name,
"conflict_score": r.conflict_score, "conflict_score": r.conflict_score,
"mode": r.mode,
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime), "thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
"image_url": image_url(r.path), "image_url": image_url(r.path),
} }
+12
View File
@@ -42,6 +42,9 @@ _EDITABLE = (
"presentation_auto_apply_enabled", "presentation_auto_apply_enabled",
"presentation_auto_apply_threshold", "presentation_auto_apply_threshold",
"presentation_conflict_threshold", "presentation_conflict_threshold",
"process_auto_apply_enabled",
"process_auto_apply_threshold",
"process_conflict_threshold",
"embedder_model_name", "embedder_model_name",
"embedder_model_version", "embedder_model_version",
*_DETECTOR_FIELDS, *_DETECTOR_FIELDS,
@@ -102,6 +105,9 @@ async def get_settings():
"presentation_auto_apply_enabled": s.presentation_auto_apply_enabled, "presentation_auto_apply_enabled": s.presentation_auto_apply_enabled,
"presentation_auto_apply_threshold": s.presentation_auto_apply_threshold, "presentation_auto_apply_threshold": s.presentation_auto_apply_threshold,
"presentation_conflict_threshold": s.presentation_conflict_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, "embedder_model_name": s.embedder_model_name,
**{f: getattr(s, f) for f in _DETECTOR_FIELDS}, **{f: getattr(s, f) for f in _DETECTOR_FIELDS},
} }
@@ -162,6 +168,12 @@ def _validate(p: dict) -> str | None:
return "presentation_auto_apply_threshold must be between 0.5 and 0.999" return "presentation_auto_apply_threshold must be between 0.5 and 0.999"
if not (0.0 <= float(p["presentation_conflict_threshold"]) <= 1.0): if not (0.0 <= float(p["presentation_conflict_threshold"]) <= 1.0):
return "presentation_conflict_threshold must be between 0 and 1" 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 # Embedder model swap (#1190): both must be non-empty. Changing them means a
# different embedding space — the operator must re-embed + retrain after. # different embedding space — the operator must re-embed + retrain after.
for key in ("embedder_model_name", "embedder_model_version"): for key in ("embedder_model_name", "embedder_model_version"):
+8
View File
@@ -49,6 +49,7 @@ _EDITABLE_FIELDS = (
"translation_target_lang", "translation_target_lang",
"translation_min_confidence", "translation_min_confidence",
"wip_title_tagging_enabled", "wip_title_tagging_enabled",
"wip_soft_title_tagging_enabled",
) )
# Per-host external-download toggles — all plain booleans, validated uniformly. # 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_target_lang": row.translation_target_lang,
"translation_min_confidence": row.translation_min_confidence, "translation_min_confidence": row.translation_min_confidence,
"wip_title_tagging_enabled": row.wip_title_tagging_enabled, "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( return jsonify(
{"error": "wip_title_tagging_enabled must be a boolean"} {"error": "wip_title_tagging_enabled must be a boolean"}
), 400 ), 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: async with get_session() as session:
row = await ImportSettings.load(session) row = await ImportSettings.load(session)
+11 -1
View File
@@ -171,9 +171,19 @@ def make_celery() -> Celery:
}, },
"presentation-auto-apply-daily": { "presentation-auto-apply-daily": {
"task": "backend.app.tasks.ml.scheduled_presentation_auto_apply", "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 # 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": { "prune-presentation-reviews-daily": {
"task": "backend.app.tasks.ml.prune_presentation_reviews", "task": "backend.app.tasks.ml.prune_presentation_reviews",
"schedule": 86400.0, # retention: drop resolved review flags >30d "schedule": 86400.0, # retention: drop resolved review flags >30d
+7
View File
@@ -126,6 +126,13 @@ class ImportSettings(Base):
wip_title_tagging_enabled: Mapped[bool] = mapped_column( wip_title_tagging_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true", 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 @classmethod
async def load(cls, session) -> ImportSettings: async def load(cls, session) -> ImportSettings:
+28 -6
View File
@@ -85,12 +85,14 @@ class MLSettings(Base):
Float, nullable=False, default=0.95 Float, nullable=False, default=0.95
) )
# -- Presentation chrome auto-hide (#141) ------------------------------- # -- Presentation chrome auto-hide (#141) -------------------------------
# banner / editor screenshot auto-apply on the sweep with their OWN flat # `banner` (chrome — clusters on UI, not content) auto-applies on the sweep
# threshold (decoupled from content-head graduation). Hiding is consequential # with its OWN flat threshold (decoupled from content-head graduation) and is
# so it runs HIGH. `wip` is never auto-applied. When an image would be # HIDDEN from the gallery. Hiding is consequential so it runs HIGH. When an
# auto-hidden but ALSO scores >= presentation_conflict_threshold on a content # image would be auto-hidden but ALSO scores >= presentation_conflict_threshold
# head, it's still hidden but flagged for review (PresentationReview) instead # on a content head, it's still hidden but flagged for review
# of buried silently. ON by default (opt-out); every auto-tag is reversible. # (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( presentation_auto_apply_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True Boolean, nullable=False, default=True
) )
@@ -100,6 +102,26 @@ class MLSettings(Base):
presentation_conflict_threshold: Mapped[float] = mapped_column( presentation_conflict_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50 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); # Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069);
# existing libraries keep their stored value until the operator re-embeds. # existing libraries keep their stored value until the operator re-embeds.
embedder_model_version: Mapped[str] = mapped_column( embedder_model_version: Mapped[str] = mapped_column(
+15 -7
View File
@@ -1,15 +1,17 @@
"""PresentationReview — an auto-hidden presentation tag that ALSO looked like """PresentationReview — a system-tag the auto-apply sweep applied that ALSO looked
real content, flagged for operator review (milestone 141). like real content, flagged for operator review (milestone 141 + #1464).
When the auto-apply sweep hides an image as chrome (banner / editor screenshot) When a sweep applies a system tag but the image ALSO scores highly on a content
but the image ALSO scores highly on a content head, it still hides it but records head, it still applies the tag but records this row so a review strip can surface
this row so the Hidden view can surface it ("⚠ also looks like <conflict tag>") it ("⚠ also looks like <conflict tag>"). Two modes (#1464): 'chrome' (banner —
for a keep-hidden / un-hide decision. Resolved rows are pruned by retention. 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 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 sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
@@ -31,6 +33,12 @@ class PresentationReview(Base):
ForeignKey("tag.id", ondelete="SET NULL"), nullable=True ForeignKey("tag.id", ondelete="SET NULL"), nullable=True
) )
conflict_score: Mapped[float] = mapped_column(Float, nullable=False) 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
+12 -7
View File
@@ -43,14 +43,19 @@ class TagKind(StrEnum):
# to keep historic tag rows queryable. # to keep historic tag rows queryable.
# The seeded system tags (migration 0075). PRESENTATION tags additionally # The seeded system tags (migration 0075). Two behavior groups (#1464):
# hide from whole-image similarity results — they cluster on UI chrome, not # CHROME (banner): clusters on UI chrome, not content → HIDDEN from the default
# content. `wip` is real art: only the training pipelines exclude it. # 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") SYSTEM_TAG_NAMES = ("wip", "banner", "editor screenshot")
PRESENTATION_SYSTEM_TAGS = ("banner", "editor screenshot") CHROME_SYSTEM_TAGS = ("banner",)
# `wip` marks real-but-unfinished art. It's kept in the gallery's own "similar" PROCESS_SYSTEM_TAGS = ("wip", "editor screenshot")
# 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).
WIP_SYSTEM_TAG = "wip" WIP_SYSTEM_TAG = "wip"
image_tag = Table( image_tag = Table(
+7 -2
View File
@@ -35,9 +35,14 @@ class InvalidUrlError(Exception):
# reviewers catch drift. # reviewers catch drift.
_PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("patreon", re.compile( ("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"^https?://(?:www\.)?patreon\.com/"
r"(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c/)" r"(?:cw/|c/)?"
r"(?P<slug>[^/?#]+)/?$", r"(?!(?:home|search|messages|notifications|library|settings|posts)(?:[/?#]|$))"
r"(?P<slug>[^/?#]+)",
re.IGNORECASE, re.IGNORECASE,
)), )),
("subscribestar", re.compile( ("subscribestar", re.compile(
+50 -14
View File
@@ -31,7 +31,7 @@ from ..models import (
Tag, Tag,
TagPositiveConfirmation, 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 .pagination import decode_cursor, encode_cursor
from .tag_query import ( from .tag_query import (
fandom_join_alias, 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] 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]: async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
"""Map image_id -> {"name","slug"} via the canonical """Map image_id -> {"name","slug"} via the canonical
image_record.artist_id (FC-2d-vii-c). Bounded by page size.""" image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
@@ -419,16 +438,17 @@ class GalleryService:
async def _hidden_tag_ids( async def _hidden_tag_ids(
self, include_hidden, tag_ids, tag_or_groups, self, include_hidden, tag_ids, tag_or_groups,
) -> list[int] | None: ) -> list[int] | None:
"""Presentation-chrome tag ids to implicitly exclude from a gallery query, """Chrome (banner) tag ids to implicitly exclude from a gallery query, or
or None. None when the caller asked to include hidden, when the operator None. None when the caller asked to include hidden, when the operator is
is explicitly filtering FOR a presentation tag (they clearly want to see explicitly filtering FOR a chrome tag (they clearly want to see it), or when
it), or when no presentation tags exist. (milestone 141)""" no chrome tags exist. (milestone 141; #1464: editor screenshot is now PROCESS
— shown — so only `banner` hides here.)"""
if include_hidden: if include_hidden:
return None return None
rows = await self.session.execute( rows = await self.session.execute(
select(Tag.id).where( select(Tag.id).where(
Tag.is_system.is_(True), Tag.is_system.is_(True),
Tag.name.in_(PRESENTATION_SYSTEM_TAGS), Tag.name.in_(CHROME_SYSTEM_TAGS),
) )
) )
pres = [r[0] for r in rows] pres = [r[0] for r in rows]
@@ -716,6 +736,7 @@ class GalleryService:
untagged: bool = False, no_artist: bool = False, untagged: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_to: datetime | None = None, date_from: datetime | None = None, date_to: datetime | None = None,
exclude_wip: bool = False, exclude_wip: bool = False,
reach: float = 0.0, exclude_ids: list[int] | None = None,
) -> list[GalleryImage] | None: ) -> list[GalleryImage] | None:
"""Visual "more like this": images near `image_id`'s SigLIP embedding """Visual "more like this": images near `image_id`'s SigLIP embedding
(pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result (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 # 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 # (5×→8×, cap 200→400) so the stronger MMR has genuinely distinct
# neighbourhoods to reach into for more variance (operator, 2026-07-01). # neighbourhoods to reach into for more variance (operator, 2026-07-01).
# 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)) pool_n = min(400, max(limit * 8, 100))
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding) distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
eff = _effective_date_col() eff = _effective_date_col()
stmt = select(ImageRecord, Post.post_date, eff.label("eff")) stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
stmt = _outer_join_primary_post(stmt) stmt = _outer_join_primary_post(stmt)
# Presentation images (banner / editor-screenshot system tags, #128) # Chrome (banner, #128) clusters on UI rather than content, so near any one
# cluster on UI chrome rather than content, so near any one of them # of them they'd fill the grid → excluded from CANDIDATES always (the anchor
# they'd fill the grid. Excluded from CANDIDATES only — the anchor # itself may be a banner). PROCESS art (wip / editor screenshot) stays
# itself may be a banner. `wip` stays surfaced here by default (real art; # surfaced here by default (real content; only the training pipelines exclude
# only the training pipelines exclude it), but the Explore rabbit-hole # it), but the Explore rabbit-hole passes exclude_wip to also drop the whole
# passes exclude_wip to also drop work-in-progress (operator, 2026-07-08). # process group so a browse doesn't keep surfacing work-in-progress
excluded_system_tags = PRESENTATION_SYSTEM_TAGS # (operator, 2026-07-08; #1464 — editor now rides with wip here).
excluded_system_tags = CHROME_SYSTEM_TAGS
if exclude_wip: if exclude_wip:
excluded_system_tags = (*PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG) excluded_system_tags = (*CHROME_SYSTEM_TAGS, *PROCESS_SYSTEM_TAGS)
presentation = ( presentation = (
select(image_tag.c.image_record_id) select(image_tag.c.image_record_id)
.join(Tag, Tag.id == image_tag.c.tag_id) .join(Tag, Tag.id == image_tag.c.tag_id)
@@ -771,6 +799,10 @@ class GalleryService:
ImageRecord.id != image_id, ImageRecord.id != image_id,
ImageRecord.id.not_in(presentation), 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 = _apply_scope(
stmt, tag_ids=tag_ids, post_id=None, stmt, tag_ids=tag_ids, post_id=None,
artist_id=artist_id, media_type=media_type, artist_id=artist_id, media_type=media_type,
@@ -780,6 +812,10 @@ class GalleryService:
) )
stmt = stmt.order_by(distance.asc()).limit(pool_n) stmt = stmt.order_by(distance.asc()).limit(pool_n)
rows = (await self.session.execute(stmt)).all() 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) rows = _diversify_similar(src, rows, limit)
artists = await _artists_for(self.session, [r[0].id for r in rows]) artists = await _artists_for(self.session, [r[0].id for r in rows])
return _gallery_images(rows, artists) return _gallery_images(rows, artists)
+22 -4
View File
@@ -47,7 +47,14 @@ from .attachment_store import AttachmentStore
from .audits import single_color from .audits import single_color
from .link_extract import extract_external_links from .link_extract import extract_external_links
from .thumbnailer import Thumbnailer 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__) log = logging.getLogger(__name__)
@@ -999,7 +1006,9 @@ class Importer:
removal sticks. The existing catalogue is covered separately by the removal sticks. The existing catalogue is covered separately by the
operator-triggered backfill sweep. Gated by the settings toggle, and operator-triggered backfill sweep. Gated by the settings toggle, and
best-effort: any failure is logged, never allowed to fail the import.""" 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 return
if record.primary_post_id is None: if record.primary_post_id is None:
return return
@@ -1007,13 +1016,22 @@ class Importer:
title = self.session.execute( title = self.session.execute(
select(Post.post_title).where(Post.id == record.primary_post_id) select(Post.post_title).where(Post.id == record.primary_post_id)
).scalar_one_or_none() ).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 return
if self._wip_tag_id is _UNSET: if self._wip_tag_id is _UNSET:
self._wip_tag_id = resolve_wip_tag_id(self.session) self._wip_tag_id = resolve_wip_tag_id(self.session)
if self._wip_tag_id is None: if self._wip_tag_id is None:
return 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 except Exception as exc: # noqa: BLE001 — a tag must never fail an import
log.warning( log.warning(
"wip-title auto-tag failed for image %s: %s", record.id, exc "wip-title auto-tag failed for image %s: %s", record.id, exc
+122 -21
View File
@@ -39,7 +39,7 @@ from ...models import (
TagPositiveConfirmation, TagPositiveConfirmation,
TagSuggestionRejection, 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 ( from .training_data import (
_AUTO_SOURCES, _AUTO_SOURCES,
_auto_apply_point, _auto_apply_point,
@@ -759,18 +759,42 @@ def auto_apply_sweep(
_PRESENTATION_SOURCE = "presentation_auto" _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): def _system_tag_heads(session: Session, embedding_version: str, names):
"""Trained heads for the presentation chrome tags (banner / editor screenshot). """Trained heads for a system-tag group (chrome banner / process wip+editor).
They fire at the FLAT presentation threshold regardless of graduation — a head They fire at the group's FLAT threshold regardless of graduation — a head
exists once the operator has labelled enough chrome (head_min_positives).""" exists once the operator has labelled enough (head_min_positives)."""
return session.execute( return session.execute(
select(TagHead.tag_id, Tag.name, TagHead.weights, TagHead.bias) select(TagHead.tag_id, Tag.name, TagHead.weights, TagHead.bias)
.join(Tag, Tag.id == TagHead.tag_id) .join(Tag, Tag.id == TagHead.tag_id)
.where(TagHead.embedding_version == embedding_version) .where(TagHead.embedding_version == embedding_version)
.where(Tag.is_system.is_(True)) .where(Tag.is_system.is_(True))
.where(Tag.name.in_(PRESENTATION_SYSTEM_TAGS)) .where(Tag.name.in_(names))
).all() ).all()
@@ -802,27 +826,33 @@ def _valued_image_ids(session: Session) -> set[int]:
return {r[0] for r in rows} return {r[0] for r in rows}
def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> dict: def system_tag_auto_apply_sweep(
"""Auto-hide presentation chrome (banner / editor screenshot) at the FLAT session: Session, *, mode: str, dry_run: bool = False
presentation threshold (#141) — NOT the per-head graduated threshold. Two ) -> dict:
guards keep it safe: (1) never hide an image carrying a human/confirmed content """Auto-apply a system-tag group at its FLAT threshold. mode='chrome' (banner,
tag; (2) if an image about to be hidden ALSO scores >= the conflict threshold #141) hides the image; mode='process' (wip / editor screenshot, #1464) keeps it
on a content head, still hide it but flag it (PresentationReview) so the Hidden VISIBLE — the ONLY difference is the tag group's gallery membership, not this
view surfaces "also looks like <X>" for review. No-op unless sweep. Two guards keep it safe: (1) never touch an image carrying a
presentation_auto_apply_enabled. numpy-only (no sklearn). Returns human/confirmed content tag; (2) if the image ALSO scores >= the conflict
{n_applied, n_flagged, concepts}.""" 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 import numpy as np
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
cfg = _SWEEP_MODES[mode]
settings = _settings(session) 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": []} return {"n_applied": 0, "n_flagged": 0, "concepts": []}
ver = settings.embedder_model_version ver = settings.embedder_model_version
pres = _presentation_heads(session, ver) pres = _system_tag_heads(session, ver, cfg["names"])
if not pres: if not pres:
return {"n_applied": 0, "n_flagged": 0, "concepts": []} return {"n_applied": 0, "n_flagged": 0, "concepts": []}
thr = float(settings.presentation_auto_apply_threshold) thr = float(getattr(settings, cfg["threshold"]))
conflict_thr = float(settings.presentation_conflict_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]) 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) 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) pg_insert(image_tag)
.values( .values(
image_record_id=iid, tag_id=tid, image_record_id=iid, tag_id=tid,
source=_PRESENTATION_SOURCE, source=source,
) )
.on_conflict_do_nothing() .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: if Wc is not None and float(max_c[idx]) >= conflict_thr:
n_flagged += 1 n_flagged += 1
if not dry_run: 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, image_record_id=iid, tag_id=tid,
conflict_tag_id=conf_tag_ids[int(arg_c[idx])], conflict_tag_id=conf_tag_ids[int(arg_c[idx])],
conflict_score=float(max_c[idx]), conflict_score=float(max_c[idx]),
mode=mode,
) )
.on_conflict_do_nothing() .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: def retract_auto_applied_heads(session: Session) -> int:
"""Soft auto-apply (milestone 139): re-score every standing source='head_auto' """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 tag against its CURRENT head and REMOVE the ones now BELOW the head's
+9 -1
View File
@@ -29,7 +29,15 @@ from ...models.tag import image_tag
# a CCIP reference) unless the operator confirms them (milestone 139). Keeping # a CCIP reference) unless the operator confirms them (milestone 139). Keeping
# auto-applied predictions out of training is what makes them "soft" — a misfire # 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. # 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]: def _hygiene_excluded_ids(session: Session) -> set[int]:
+31 -7
View File
@@ -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 # 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. # 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" 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" # A standalone "WIP" / "W.I.P" token, or the phrase "work in progress"
# (space/underscore/hyphen separated). The letter-boundary lookarounds are what # (space/underscore/hyphen separated). The letter-boundary lookarounds are what
@@ -39,11 +45,20 @@ _WIP_RE = re.compile(
re.IGNORECASE, 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. # COULD match before the precise regex confirms. Case-insensitive ILIKE patterns.
# MUST stay a SUPERSET of _WIP_RE (every regex match contains "wip" or # Each MUST stay a SUPERSET of its regex or the sweep would silently miss posts.
# "work…progress") or the sweep would silently miss posts.
WIP_TITLE_SQL_PREFILTER = ("%wip%", "%work%progress%") 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 # 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). # 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: 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: if not title:
return False return False
return _WIP_RE.search(title) is not None 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: def resolve_wip_tag_id(session: Session) -> int | None:
"""The seeded ``wip`` system tag's id (migration 0075), or None if absent.""" """The seeded ``wip`` system tag's id (migration 0075), or None if absent."""
return session.execute( return session.execute(
@@ -64,8 +86,10 @@ def resolve_wip_tag_id(session: Session) -> int | None:
).scalar_one_or_none() ).scalar_one_or_none()
def apply_wip_image_tags(session: Session, image_ids, tag_id: int) -> int: def apply_wip_image_tags(
"""Attach ``tag_id`` (source='wip_title') to each image id, idempotently — 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 never disturbs an existing tag or its source. Returns the number of image_tag
rows newly inserted. Does NOT commit. 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( session.execute(
pg_insert(image_tag) pg_insert(image_tag)
.values([ .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 for iid in to_insert
]) ])
.on_conflict_do_nothing(index_elements=["image_record_id", "tag_id"]) .on_conflict_do_nothing(index_elements=["image_record_id", "tag_id"])
+61 -40
View File
@@ -1047,6 +1047,41 @@ def cleanup_old_download_events() -> int:
return result.rowcount or 0 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( @celery.task(
name="backend.app.tasks.maintenance.backfill_wip_title_tags", name="backend.app.tasks.maintenance.backfill_wip_title_tags",
# Coarse-prefiltered scan over posts; the candidate set is small on a typical # 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, soft_time_limit=1800, time_limit=2100,
) )
def backfill_wip_title_tags() -> int: def backfill_wip_title_tags() -> int:
"""Scan EXISTING posts for explicit WIP titles and apply the `wip` system tag """Scan EXISTING posts for WIP titles and apply the `wip` system tag to their
to their images — the operator-triggered back-catalogue catch-up for images — the operator-triggered back-catalogue catch-up (task #1458 hard tier +
title-based WIP tagging (task #1458). New imports are tagged live by the #1474 soft tier). New imports are tagged live by the importer; this covers the
importer; this covers everything already in the library. existing library.
Keyset-paginated over posts (restart-safe). A coarse SQL prefilter narrows to HARD tier ("WIP"/"work in progress") always runs (the operator triggered the
titles that COULD match; the precise regex (matches_wip_title) confirms. scan); the SOFT tier (sketch/doodle, provisional source) runs only when
Idempotent-additive (ON CONFLICT DO NOTHING) — never disturbs an existing tag. 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 Deliberately NOT scheduled as a beat: a periodic re-run would re-apply to matching
matching posts and silently undo a manual WIP removal, so it stays an explicit posts and silently undo a manual WIP removal, so it stays an explicit operator
operator action (Settings → "Scan existing posts for WIP titles"). Returns the action (Settings → "Scan existing posts for WIP titles"). Returns rows applied.
number of image-tag rows newly applied.
""" """
from ..models import Post from ..models import ImportSettings
from ..models.image_provenance import ImageProvenance
from ..services.wip_title import ( from ..services.wip_title import (
SOFT_WIP_TITLE_SQL_PREFILTER,
WIP_TITLE_SOFT_SOURCE,
WIP_TITLE_SOURCE,
WIP_TITLE_SQL_PREFILTER, WIP_TITLE_SQL_PREFILTER,
apply_wip_image_tags, matches_soft_wip_title,
matches_wip_title, matches_wip_title,
resolve_wip_tag_id, resolve_wip_tag_id,
) )
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
applied = 0
last_id = 0
with SessionLocal() as session: with SessionLocal() as session:
tag_id = resolve_wip_tag_id(session) tag_id = resolve_wip_tag_id(session)
if tag_id is None: 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" "backfill_wip_title_tags: no `wip` system tag present; nothing to do"
) )
return 0 return 0
like_a, like_b = WIP_TITLE_SQL_PREFILTER settings = ImportSettings.load_sync(session)
while True: applied = _backfill_wip_tier(
rows = session.execute( session, tag_id, WIP_TITLE_SQL_PREFILTER, matches_wip_title,
select(Post.id, Post.post_title) WIP_TITLE_SOURCE,
.where(Post.id > last_id) )
.where(Post.post_title.is_not(None)) if settings.wip_soft_title_tagging_enabled:
.where(or_( applied += _backfill_wip_tier(
Post.post_title.ilike(like_a), session, tag_id, SOFT_WIP_TITLE_SQL_PREFILTER, matches_soft_wip_title,
Post.post_title.ilike(like_b), WIP_TITLE_SOFT_SOURCE,
)) )
.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()
if applied: if applied:
log.info("backfill_wip_title_tags: applied wip to %d image(s)", applied) log.info("backfill_wip_title_tags: applied wip to %d image(s)", applied)
return applied return applied
+42 -6
View File
@@ -599,18 +599,54 @@ def scheduled_ccip_auto_apply() -> str:
soft_time_limit=1800, time_limit=2100, soft_time_limit=1800, time_limit=2100,
) )
def scheduled_presentation_auto_apply() -> str: def scheduled_presentation_auto_apply() -> str:
"""Auto-hide presentation chrome (banner / editor screenshot) on a daily """Auto-hide presentation chrome (banner) on a daily passive sweep (#141).
passive sweep (#141). No-op unless presentation_auto_apply_enabled. Idempotent No-op unless presentation_auto_apply_enabled. Idempotent — already-tagged images
— already-hidden images are skipped — so an interrupted run simply re-runs next are skipped — so an interrupted run simply re-runs next cycle (that IS the
cycle (that IS the recovery). Wall-clock bounded by the task time limits.""" recovery). Wall-clock bounded by the task time limits."""
from ..services.ml.heads import presentation_auto_apply_sweep from ..services.ml.heads import system_tag_auto_apply_sweep
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
with SessionLocal() as session: 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']}" 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") @celery.task(name="backend.app.tasks.ml.prune_presentation_reviews")
def prune_presentation_reviews() -> str: def prune_presentation_reviews() -> str:
"""Retention (rule 89): drop RESOLVED presentation-review flags older than 30 """Retention (rule 89): drop RESOLVED presentation-review flags older than 30
+10 -1
View File
@@ -86,7 +86,16 @@ const PLATFORMS = {
* script to decide whether to show the floating "Add as source" button. * script to decide whether to show the floating "Add as source" button.
*/ */
const PLATFORM_ARTIST_PATTERNS = { 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, subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i,
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i, hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i,
deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i, deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "FabledCurator", "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.", "description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
"browser_specific_settings": { "browser_specific_settings": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "fabledcurator-extension", "name": "fabledcurator-extension",
"version": "1.0.7", "version": "1.0.8",
"private": true, "private": true,
"description": "Firefox extension for FabledCurator", "description": "Firefox extension for FabledCurator",
"scripts": { "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" "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": { "devDependencies": {
"web-ext": "^8.0.0" "web-ext": "^10.0.0"
} }
} }
+6 -6
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22" "node": ">=24"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -13,16 +13,16 @@
"test:unit": "vitest run" "test:unit": "vitest run"
}, },
"dependencies": { "dependencies": {
"vue": "^3.4.0", "vue": "^3.5.0",
"vue-router": "^4.3.0", "vue-router": "^5.0.0",
"pinia": "^2.1.0", "pinia": "^3.0.0",
"vuetify": "^3.5.0", "vuetify": "^4.0.0",
"@mdi/font": "^7.4.0" "@mdi/font": "^7.4.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.0",
"vite": "^8.0.0", "vite": "^8.0.0",
"vite-plugin-vuetify": "^2.0.0", "vite-plugin-vuetify": "^2.1.0",
"sass": "^1.71.0", "sass": "^1.71.0",
"vitest": "^4.0.0", "vitest": "^4.0.0",
"@vue/test-utils": "^2.4.0", "@vue/test-utils": "^2.4.0",
+6 -6
View File
@@ -18,11 +18,11 @@ const route = useRoute()
<style scoped> <style scoped>
.fc-content { .fc-content {
min-height: 100vh; min-height: 100vh;
/* Push initial viewport content below the sticky TopNav. Without /* NO padding-top: the TopNav is position:sticky, so it already reserves its
this, some views' first rows / form fields / table headers can own space in the v-app flex column — content flows directly below it. The
end up obscured by the navbar (depending on parent overflow old 64px padding-top was a leftover from a FIXED navbar and double-counted
context interacting with position: sticky). Scrolled-down content that space, leaving a large empty band at the top of EVERY view (and pushing
still slides under the nav — the gradient-fade design is intact. */ the full-height calc(100vh - 64px) views down so they overflowed). Removed
padding-top: 64px; 2026-07-13. Scrolled content still slides under the sticky nav as before. */
} }
</style> </style>
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<v-snackbar <v-snackbar
v-model="show" :color="color" location="bottom right" timeout="4000" v-model="show" :color="color" location="bottom right" timeout="4000"
multi-line elevation="4" min-height="68" elevation="4"
> >
{{ message }} {{ message }}
<template #actions> <template #actions>
+52 -7
View File
@@ -1,5 +1,5 @@
<template> <template>
<header class="fc-topnav"> <header ref="navEl" class="fc-topnav" :class="{ 'fc-topnav--chrome': hasStickyChrome }">
<div class="fc-nav-left"> <div class="fc-nav-left">
<RouterLink :to="FRONT_DOOR" class="fc-brand" aria-label="FabledCurator home"> <RouterLink :to="FRONT_DOOR" class="fc-brand" aria-label="FabledCurator home">
<img src="/favicon.svg" alt="" class="fc-brand__glyph" width="22" height="22" /> <img src="/favicon.svg" alt="" class="fc-brand__glyph" width="22" height="22" />
@@ -64,13 +64,39 @@
</template> </template>
<script setup> <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 router, { FRONT_DOOR } from '../router.js'
import { useSystemStore } from '../stores/system.js' import { useSystemStore } from '../stores/system.js'
import PipelineStatusChip from './PipelineStatusChip.vue' import PipelineStatusChip from './PipelineStatusChip.vue'
const system = useSystemStore() 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 — // Every route with a meta.title is a nav entry. Order by meta.navOrder —
// router.getRoutes() does NOT guarantee declaration order, so explicit numbers // router.getRoutes() does NOT guarantee declaration order, so explicit numbers
@@ -119,16 +145,35 @@ const health = computed(() => {
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 0.75rem 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( background: linear-gradient(
to bottom, to bottom,
rgba(20, 23, 26, 0.92) 0%, rgba(var(--fc-chrome-rgb), 0.92) 0%,
rgba(20, 23, 26, 0.65) 60%, rgba(var(--fc-chrome-rgb), 0.84) 50%,
rgba(20, 23, 26, 0) 100% rgba(var(--fc-chrome-rgb), 0.55) 75%,
rgba(var(--fc-chrome-rgb), 0) 100%
); );
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
-webkit-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 { .fc-brand {
display: flex; display: flex;
@@ -10,7 +10,7 @@
filter, applied retroactively to the existing library. filter, applied retroactively to the existing library.
</p> </p>
<v-row dense> <v-row density="compact">
<v-col cols="6"> <v-col cols="6">
<v-text-field <v-text-field
v-model.number="minW" label="Min width (px)" type="number" v-model.number="minW" label="Min width (px)" type="number"
@@ -13,7 +13,7 @@
cadence as the transparency audit. cadence as the transparency audit.
</p> </p>
<v-row dense> <v-row density="compact">
<v-col cols="6"> <v-col cols="6">
<v-text-field <v-text-field
v-model.number="threshold" label="Threshold (01)" v-model.number="threshold" label="Threshold (01)"
+3 -3
View File
@@ -14,10 +14,10 @@
@update:search="onSearch" @update:search="onSearch"
@update:model-value="onPick" @update:model-value="onPick"
> >
<template #item="{ props: itemProps, item }"> <template #item="{ props: itemProps, internalItem }">
<v-list-item v-bind="itemProps" :title="item.raw.name"> <v-list-item v-bind="itemProps" :title="internalItem.raw.name">
<template #subtitle> <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> </template>
</v-list-item> </v-list-item>
</template> </template>
@@ -1,5 +1,5 @@
<template> <template>
<div class="fc-filterbar-wrap"> <div class="fc-filterbar-wrap fc-chrome-continues">
<div class="fc-filterbar"> <div class="fc-filterbar">
<v-autocomplete <v-autocomplete
v-model="selected" v-model="selected"
@@ -13,14 +13,14 @@
@update:search="onSearch" @update:search="onSearch"
@update:model-value="onPick" @update:model-value="onPick"
> >
<template #item="{ props: itemProps, item }"> <template #item="{ props: itemProps, internalItem }">
<v-list-item v-bind="itemProps" :title="item.raw.name"> <v-list-item v-bind="itemProps" :title="internalItem.raw.name">
<template #prepend> <template #prepend>
<v-icon size="small">{{ iconFor(item.raw) }}</v-icon> <v-icon size="small">{{ iconFor(internalItem.raw) }}</v-icon>
</template> </template>
<template #subtitle> <template #subtitle>
{{ item.raw.kind === 'artist' ? 'artist' {{ internalItem.raw.kind === 'artist' ? 'artist'
: (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> </template>
</v-list-item> </v-list-item>
</template> </template>
@@ -306,27 +306,17 @@ function pushFilter(mutate) {
frosted block pinned directly under the 64px TopNav and continuous with it. */ frosted block pinned directly under the 64px TopNav and continuous with it. */
.fc-filterbar-wrap { .fc-filterbar-wrap {
position: sticky; position: sticky;
top: 64px; top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
z-index: 5; z-index: 5;
/* Attach to the TopNav: cancel the v-container's top padding (pt-2 = 8px) /* 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 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. */ and a gap shows through when scrolled to the top. */
margin-top: -8px; margin-top: -8px;
margin-bottom: 12px; margin-bottom: 12px;
/* EXACT same gradiated obsidian (#14171A = 20,23,26) frost as the TopNav so /* The frost itself (obsidian fade + blur) is the shared .fc-chrome-continues
the two read as one continuous piece of chrome — images scroll visibly primitive: it CONTINUES the TopNav's fade from the seam alpha to transparent
under both. The nav's gradient fades to transparent at ITS bottom; this rather than re-darkening, so the nav + bar read as one gradient (operator
bar re-darkens at its top, so a faint seam (the page/image showing through 2026-07-13). This block only owns the sticky positioning now. */
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);
} }
.fc-filterbar { .fc-filterbar {
display: flex; display: flex;
@@ -341,6 +331,20 @@ function pushFilter(mutate) {
.fc-filterbar-wrap :deep(.v-btn-group) { .fc-filterbar-wrap :deep(.v-btn-group) {
background-color: rgba(20, 23, 26, 0.72); 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__search { max-width: 320px; min-width: 200px; }
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
/* The tag chips' bodies toggle include/exclude — signal they're clickable. */ /* The tag chips' bodies toggle include/exclude — signal they're clickable. */
@@ -1,14 +1,15 @@
<template> <template>
<!-- Auto-hidden chrome that ALSO looked like real content surfaced PROACTIVELY <!-- System-tag auto-applies (chrome hides / process WIP tags) that ALSO looked
atop the gallery whenever there's something to review (NOT gated on the like real content surfaced PROACTIVELY atop the gallery whenever there's
Show-hidden toggle, so misfires can't go unnoticed), most-concerning first, something to review (NOT gated on the Show-hidden toggle, so misfires can't
with keep / un-hide (#141). Renders nothing when there's nothing to review. --> go unnoticed), most-concerning first, with keep / remove (#141, #1464).
<section v-if="items.length" class="fc-review" aria-label="Hidden images to review"> 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"> <div class="fc-review__head">
<v-icon size="18" color="warning">mdi-alert-outline</v-icon> <v-icon size="18" color="warning">mdi-alert-outline</v-icon>
<span class="fc-review__title"> <span class="fc-review__title">
{{ items.length }} auto-hidden {{ items.length === 1 ? 'image' : 'images' }} {{ items.length }} auto-tagged {{ items.length === 1 ? 'image' : 'images' }}
may be real content — review before they stay hidden may be real content — review
</span> </span>
</div> </div>
<div class="fc-review__cards"> <div class="fc-review__cards">
@@ -26,16 +27,16 @@
> >
also looks like <strong>{{ it.conflict_name || 'content' }}</strong> also looks like <strong>{{ it.conflict_name || 'content' }}</strong>
</div> </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"> <div class="fc-review-card__acts">
<button <button
type="button" class="fc-review-btn fc-review-btn--keep" type="button" class="fc-review-btn fc-review-btn--keep"
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'keep')" :disabled="busy.includes(keyOf(it))" @click="resolve(it, 'keep')"
>Keep hidden</button> >{{ keepLabel(it) }}</button>
<button <button
type="button" class="fc-review-btn fc-review-btn--unhide" type="button" class="fc-review-btn fc-review-btn--unhide"
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'unhide')" :disabled="busy.includes(keyOf(it))" @click="resolve(it, 'unhide')"
>Un-hide</button> >{{ removeLabel(it) }}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -54,6 +55,11 @@ const items = ref([])
const busy = ref([]) const busy = ref([])
function keyOf(it) { return `${it.image_id}:${it.tag_id}` } 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() { async function load() {
// Fetched unconditionally on mount — the strip prompts for pending misfires // 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}`) await api.post(`/api/gallery/hidden-review/${it.image_id}/${it.tag_id}/${action}`)
items.value = items.value.filter((x) => keyOf(x) !== k) items.value = items.value.filter((x) => keyOf(x) !== k)
if (action === 'unhide') { 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) { } catch (e) {
toast({ toast({
+75 -6
View File
@@ -171,13 +171,13 @@
/> />
</div> </div>
<p class="fc-muted text-body-2 mb-3"> <p class="fc-muted text-body-2 mb-3">
Auto-hide banners and editor screenshots from the gallery once a head has Auto-hide <code>banner</code> chrome from the gallery once a head has
learned them ( {{ minPositives }} examples) and clears learned it ( {{ minPositives }} examples) and clears
{{ Math.round((presentationThresholdInput || 0) * 100) }}% confidence. {{ Math.round((presentationThresholdInput || 0) * 100) }}% confidence.
<code>wip</code> is never auto-hidden. If a hidden image also looks like (<code>wip</code> and <code>editor screenshot</code> are handled by the
real content ( {{ Math.round((presentationConflictInput || 0) * 100) }}% process auto-tagger below.) If a hidden image also looks like real content
on a content tag), it's flagged in the Hidden view instead of buried. ( {{ Math.round((presentationConflictInput || 0) * 100) }}% on a content
Every auto-hide is reversible. tag), it's flagged for review instead of buried. Every auto-hide is reversible.
</p> </p>
<div class="d-flex mb-3" style="gap: 12px;"> <div class="d-flex mb-3" style="gap: 12px;">
<v-text-field <v-text-field
@@ -195,6 +195,43 @@
</div> </div>
</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 --> <!-- Performance / tuning -->
<div v-if="metricsConcepts.length" class="mt-5"> <div v-if="metricsConcepts.length" class="mt-5">
<div class="fc-section-h mb-1">How auto-apply is landing</div> <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 presentationEnabled = ref(true)
const presentationThresholdInput = ref(0.90) const presentationThresholdInput = ref(0.90)
const presentationConflictInput = ref(0.50) 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 autoRunning = computed(() => autoStatus.value?.running_id != null)
const lastSweep = computed(() => const lastSweep = computed(() =>
@@ -292,6 +332,9 @@ onMounted(async () => {
presentationEnabled.value = s.presentation_auto_apply_enabled ?? true presentationEnabled.value = s.presentation_auto_apply_enabled ?? true
presentationThresholdInput.value = s.presentation_auto_apply_threshold ?? 0.90 presentationThresholdInput.value = s.presentation_auto_apply_threshold ?? 0.90
presentationConflictInput.value = s.presentation_conflict_threshold ?? 0.50 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 */ } } catch { /* non-fatal */ }
await refresh() await refresh()
if (running.value) startPoll() if (running.value) startPoll()
@@ -402,6 +445,32 @@ async function onSavePresentation() {
settingBusy.value = false 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 onPreview() { startSweep(true) }
function onApplyNow() { startSweep(false) } function onApplyNow() { startSweep(false) }
async function startSweep(dryRun) { async function startSweep(dryRun) {
@@ -15,7 +15,7 @@
being dropped as duplicates;</strong> raise it to collapse more being dropped as duplicates;</strong> raise it to collapse more
look-alikes. Applies to new imports. look-alikes. Applies to new imports.
</div> </div>
<v-row align="center" no-gutters> <v-row no-gutters class="align-center">
<v-col cols="12" sm="9"> <v-col cols="12" sm="9">
<v-slider <v-slider
v-model="local.phash_threshold" v-model="local.phash_threshold"
@@ -99,6 +99,17 @@
the Explore browse. Applies to new imports; run the scan below to catch the Explore browse. Applies to new imports; run the scan below to catch
posts already in your library. posts already in your library.
</div> </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 <v-btn
variant="tonal" color="primary" size="small" variant="tonal" color="primary" size="small"
:loading="store.wipScanBusy" prepend-icon="mdi-magnify" :loading="store.wipScanBusy" prepend-icon="mdi-magnify"
@@ -139,6 +150,7 @@ const local = reactive({
skip_single_color: false, single_color_threshold: 0.95, skip_single_color: false, single_color_threshold: 0.95,
phash_threshold: 10, phash_threshold: 10,
wip_title_tagging_enabled: true, wip_title_tagging_enabled: true,
wip_soft_title_tagging_enabled: false,
}) })
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true }) watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
@@ -1,5 +1,5 @@
<template> <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-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 class="fc-stat">
<v-card-text> <v-card-text>
+5 -5
View File
@@ -20,7 +20,7 @@ const routes = [
// FC-2: image backbone // FC-2: image backbone
{ path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase', navOrder: 10 } }, { 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 // Explore: a 3-pane tagging workspace — walk an image's visual neighbours
// (left) while tagging the focused image (center viewer + modal-parity tag // (left) while tagging the focused image (center viewer + modal-parity tag
// rail). Optional anchor param — the bare /explore nav entry SEEDS a random // 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 — // 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 // the three "browse the library by an axis" surfaces. One nav entry; the old
// standalone paths redirect into the matching tab (below). // 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). // Artist detail — no meta.title (reached by clicking an artist, not nav).
{ path: '/artist/:slug', name: 'artist', component: ArtistView }, { path: '/artist/:slug', name: 'artist', component: ArtistView },
// Series browse — a nav entry (meta.title). // 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). // Series management — no meta.title (reached from a series card/tag).
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView }, { path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
// Series reader — immersive (no top nav, no meta.title). // Series reader — immersive (no top nav, no meta.title).
@@ -41,10 +41,10 @@ const routes = [
// FC-3: subscription backbone — purely management (sources/downloads), // FC-3: subscription backbone — purely management (sources/downloads),
// distinct from the Browse hub. // 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). // 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 // 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 // deep-link query (e.g. /posts?post_id=N → /browse?tab=posts&post_id=N). The
+21 -3
View File
@@ -25,6 +25,10 @@ export const useExploreStore = defineStore('explore', () => {
const cursor = ref(-1) const cursor = ref(-1)
const loading = ref(false) const loading = ref(false)
const error = ref(null) 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() const inflight = useInflightToken()
@@ -46,7 +50,13 @@ export const useExploreStore = defineStore('explore', () => {
const body = await api.get('/api/gallery/similar', { const body = await api.get('/api/gallery/similar', {
// exclude_wip: keep work-in-progress out of the Explore rabbit-hole // 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. // (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 if (!t.isCurrent()) return
neighbors.value = body.images || [] neighbors.value = body.images || []
@@ -113,6 +123,14 @@ export const useExploreStore = defineStore('explore', () => {
loading.value = false 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 --------------------------------------------- // --- TagPanel "host" surface ---------------------------------------------
// The anchor IS the current image (same /api/gallery/image/<id> payload the // 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 // modal uses), so these mirror the modal store's tag-CRUD, targeting the
@@ -189,8 +207,8 @@ export const useExploreStore = defineStore('explore', () => {
function close () {} function close () {}
return { return {
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT, anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT, reach,
anchorOn, reset, backTarget, forwardTarget, anchorOn, reset, backTarget, forwardTarget, setReach,
// host surface // host surface
current, currentImageId, current, currentImageId,
reloadTags, addExistingTag, removeTag, createAndAdd, close, reloadTags, addExistingTag, removeTag, createAndAdd, close,
+74
View File
@@ -39,3 +39,77 @@
deliberately not used here. `.fc-muted` is a custom class Vuetify never deliberately not used here. `.fc-muted` is a custom class Vuetify never
emits, so no specificity/reorder fight — no !important needed. */ emits, so no specificity/reorder fight — no !important needed. */
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); } .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);
}
+4 -3
View File
@@ -7,7 +7,7 @@
switcher and the search stay reachable no matter how far you scroll. switcher and the search stay reachable no matter how far you scroll.
Background uses the theme surface token so content scrolls cleanly Background uses the theme surface token so content scrolls cleanly
under it (matches SettingsView's sticky tabs). --> 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"> <v-container fluid class="py-0">
<!-- Tabs and search share one row: the axis switcher on the left, the <!-- Tabs and search share one row: the axis switcher on the left, the
search field + active-scope chips on the right (operator-asked search field + active-scope chips on the right (operator-asked
@@ -154,9 +154,10 @@ function clearFilter(key) {
<style scoped> <style scoped>
.fc-browse__head { .fc-browse__head {
position: sticky; 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; 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 { .fc-browse__bar {
display: flex; display: flex;
+33 -2
View File
@@ -31,6 +31,20 @@
<img :src="c.thumbnail_url" alt="" loading="lazy" /> <img :src="c.thumbnail_url" alt="" loading="lazy" />
</button> </button>
<div class="fc-ex__trail-actions"> <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 <!-- Active retrain right where you tag: fold the +/- you just gave
into the heads without a trip to Settings (the nightly beat is the into the heads without a trip to Settings (the nightly beat is the
passive cadence). --> passive cadence). -->
@@ -153,6 +167,12 @@ const modal = useModalStore()
const anchorId = computed(() => route.params.imageId || null) const anchorId = computed(() => route.params.imageId || null)
const isVideo = computed(() => !!store.anchor?.mime?.startsWith('video/')) 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 // #1206: hovering a suggestion in the rail highlights the crop it came from on
// the anchor image (same provide/inject as the modal viewer). // the anchor image (same provide/inject as the modal viewer).
@@ -266,10 +286,13 @@ onUnmounted(() => {
<style scoped> <style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); } .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 { .fc-ex {
display: flex; flex-direction: column; display: flex; flex-direction: column;
height: calc(100vh - 64px); height: calc(100vh - var(--fc-nav-h, 64px));
min-height: 0; min-height: 0;
} }
@@ -296,6 +319,14 @@ onUnmounted(() => {
.fc-ex__trail-actions { .fc-ex__trail-actions {
margin-left: auto; display: flex; align-items: center; gap: 4px; flex: 0 0 auto; 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. /* 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 grid-template-rows: minmax(0, 1fr) BOUNDS the single row to the container
+5 -4
View File
@@ -4,7 +4,7 @@
the 64px TopNav (operator-asked 2026-06-12), so the axis switcher and 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 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. --> 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-tabs v-model="tab" density="compact">
<v-tab value="browse">Browse</v-tab> <v-tab value="browse">Browse</v-tab>
<v-tab value="suggestions"> <v-tab value="suggestions">
@@ -294,12 +294,13 @@ onMounted(() => {
<style scoped> <style scoped>
/* Sticky header (tabs + active-tab controls) pinned under the 64px TopNav, so /* 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 { .fc-series__head {
position: sticky; position: sticky;
top: 64px; top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
z-index: 4; z-index: 4;
background: rgb(var(--v-theme-surface));
padding-bottom: 12px; padding-bottom: 12px;
} }
.fc-series-browse__controls { .fc-series-browse__controls {
+4 -6
View File
@@ -7,13 +7,11 @@
<!-- Sticky tabs: operator-flagged 2026-05-25 long Import / Maintenance <!-- Sticky tabs: operator-flagged 2026-05-25 long Import / Maintenance
panels pushed the tab strip out of the viewport, forcing a scroll- 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 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 tab strip lives directly under it. The .fc-chrome-continues fade
token so it visually merges with the page rather than the continues the nav's gradient across the strip (operator 2026-07-13). -->
translucent v-tabs default. -->
<v-tabs <v-tabs
v-model="tab" color="accent" class="mb-4" v-model="tab" color="accent" class="mb-4 fc-chrome-continues"
style="position: sticky; top: 64px; z-index: 4; style="position: sticky; top: var(--fc-nav-h, 64px); z-index: 4;"
background: rgb(var(--v-theme-surface));"
> >
<v-tab value="overview">Overview</v-tab> <v-tab value="overview">Overview</v-tab>
<v-tab value="activity">Activity</v-tab> <v-tab value="activity">Activity</v-tab>
+7 -4
View File
@@ -5,7 +5,7 @@
align-tabs="start" align-tabs="start"
color="accent" color="accent"
density="compact" density="compact"
class="fc-subs-tabs" class="fc-subs-tabs fc-chrome-continues"
> >
<v-tab value="subscriptions"> <v-tab value="subscriptions">
<v-icon start>mdi-account-multiple-check</v-icon> <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 /* Fixed-height hub: the tabs (and each tab's sticky control bar) stay
put while ONLY the tab content scrolls — previously the whole view put while ONLY the tab content scrolls — previously the whole view
scrolled instead of just the subscription list (operator-flagged scrolled instead of just the subscription list (operator-flagged
2026-05-28). 64px = the TopNav height (AppShell .fc-content pad-top). */ 2026-05-28). --fc-nav-h = the TopNav's real measured height (#1481). */
height: calc(100vh - 64px); height: calc(100vh - var(--fc-nav-h, 64px));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
.fc-subs-tabs { .fc-subs-tabs {
flex: 0 0 auto; 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 { .fc-subs-window {
flex: 1 1 auto; flex: 1 1 auto;
+65
View File
@@ -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")
+9 -5
View File
@@ -82,24 +82,28 @@ async def _system_tag(db, name):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scroll_hides_presentation_chrome_by_default(db): async def test_scroll_hides_presentation_chrome_by_default(db):
# banner / editor screenshot (presentation system tags) are hidden from the # banner (chrome) is hidden from the default gallery; wip AND editor screenshot
# default gallery; wip (also a system tag) is NOT — it's real, in-progress # (the PROCESS system tags) are NOT — they're real art / process shots that stay
# art (milestone 141). Seeded system tags survive the harness TRUNCATE. # visible (milestone 141 + #1464). Seeded system tags survive the harness TRUNCATE.
imgs = await _seed_images(db, 3, sha_prefix="p") imgs = await _seed_images(db, 4, sha_prefix="p")
banner = await _system_tag(db, "banner") banner = await _system_tag(db, "banner")
wip = await _system_tag(db, "wip") wip = await _system_tag(db, "wip")
editor = await _system_tag(db, "editor screenshot")
await db.execute(image_tag.insert().values( await db.execute(image_tag.insert().values(
image_record_id=imgs[0].id, tag_id=banner.id, source="manual")) image_record_id=imgs[0].id, tag_id=banner.id, source="manual"))
await db.execute(image_tag.insert().values( await db.execute(image_tag.insert().values(
image_record_id=imgs[1].id, tag_id=wip.id, source="manual")) 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() await db.flush()
svc = GalleryService(db) 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} 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[0].id not in default_ids # banner hidden
assert imgs[1].id in default_ids # wip visible assert imgs[1].id in default_ids # wip visible
assert imgs[2].id in default_ids # plain 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). # include_hidden surfaces the banner image (the Hidden view).
shown = {i.id for i in ( shown = {i.id for i in (
+17
View File
@@ -170,6 +170,23 @@ async def test_similar_respects_limit(db):
assert len(res) == 2 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 --- # --- API ---
@pytest.mark.asyncio @pytest.mark.asyncio
+6 -6
View File
@@ -15,7 +15,7 @@ from backend.app.models import (
from backend.app.models.tag import image_tag from backend.app.models.tag import image_tag
from backend.app.services.ml.heads import ( from backend.app.services.ml.heads import (
auto_apply_sweep, auto_apply_sweep,
presentation_auto_apply_sweep, system_tag_auto_apply_sweep,
) )
pytestmark = pytest.mark.integration 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) _head(db_sync, banner.id, 0, weight=3.0)
img = _img(db_sync, "a" * 64, _emb(0)) img = _img(db_sync, "a" * 64, _emb(0))
db_sync.commit() 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_applied"] == 1
assert _source(db_sync, img.id, banner.id) == "presentation_auto" 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( db_sync.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=content.id, source="manual")) image_record_id=img.id, tag_id=content.id, source="manual"))
db_sync.commit() 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 res["n_applied"] == 0
assert _source(db_sync, img.id, banner.id) is None 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 _head(db_sync, content.id, 0, weight=1.0) # content head also fires
img = _img(db_sync, "c" * 64, _emb(0)) img = _img(db_sync, "c" * 64, _emb(0))
db_sync.commit() 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_applied"] == 1
assert res["n_flagged"] == 1 assert res["n_flagged"] == 1
assert _source(db_sync, img.id, banner.id) == "presentation_auto" 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) _head(db_sync, banner.id, 0, weight=3.0)
img = _img(db_sync, "d" * 64, _emb(0)) img = _img(db_sync, "d" * 64, _emb(0))
db_sync.commit() 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 res["n_applied"] == 0
assert _source(db_sync, img.id, banner.id) is None 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) _head(db_sync, wip.id, 0, weight=3.0)
img = _img(db_sync, "e" * 64, _emb(0)) img = _img(db_sync, "e" * 64, _emb(0))
db_sync.commit() 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 res["n_applied"] == 0
assert _source(db_sync, img.id, wip.id) is None assert _source(db_sync, img.id, wip.id) is None
+188
View File
@@ -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
+32
View File
@@ -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
View File
@@ -8,7 +8,7 @@ negative cases (substrings like ``swipe`` / ``wiped``) are the load-bearing ones
""" """
import pytest 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", [ @pytest.mark.parametrize("title", [
@@ -44,6 +44,33 @@ def test_matches_positive(title):
"finished at last", "finished at last",
"Kawips diner", # 'wip' mid-word "Kawips diner", # 'wip' mid-word
"swipright", "swipright",
"quick sketch of Nami", # soft cue — NOT a HARD WIP match
]) ])
def test_matches_negative(title): def test_matches_negative(title):
assert matches_wip_title(title) is False 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
+16 -1
View File
@@ -9,7 +9,11 @@ from sqlalchemy import select
from backend.app.celery_app import celery from backend.app.celery_app import celery
from backend.app.models import Artist, ImageProvenance, ImageRecord, Post, Source from backend.app.models import Artist, ImageProvenance, ImageRecord, Post, Source
from backend.app.models.tag import image_tag 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 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. # Idempotent: a second sweep finds the tag already present and applies nothing.
assert backfill_wip_title_tags.apply().get() == 0 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"