Compare commits

...
252 Commits
Author SHA1 Message Date
bvandeusen 8300029741 Merge pull request 'Extension: fix credential-push 405, guard the publish path, add a unit suite' (#234) from dev into main
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 32s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 1m14s
Build images / sign-extension (push) Successful in 2m23s
Build images / build-ml (push) Successful in 3m53s
CI / integration (push) Successful in 3m56s
Build images / build-web (push) Successful in 2m34s
Build images / build-agent (push) Successful in 13m33s
2026-08-03 08:29:09 -04:00
Claude f9111c06a7 test(extension): unit suite for lib/ + version-consistency specs
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 21s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 43s
CI / integration (push) Successful in 3m53s
extension / lint (pull_request) Successful in 19s
extension/ had no test harness at all -- web-ext lint was the only signal, so
the URL-normalization fix in 8214afe shipped with nothing exercising it.

Adds vitest (mirroring frontend/vitest.config.js) and three specs:

- url.spec.js       normalizeApiUrl / webRootFromApiUrl, including the #2393
                    regression: instance-root input must reach /api/credentials,
                    idempotence, trailing-slash and whitespace handling, and
                    that empty input never yields a bare "/api" (which
                    isConfigured() would read as configured).
- platforms.spec.js getPlatformFromUrl / isArtistPage, pinning the #1485
                    regression -- all three Patreon creator URL shapes
                    (bare, /c/, /cw/) plus inner pages, with nav pages
                    excluded -- and table-integrity checks.
- version.spec.js   manifest.json and package.json versions in lockstep,
                    AMO-safe version format, and url.js ordered before api.js
                    in background.scripts (classic scripts share one scope, so
                    a reorder is a runtime ReferenceError with no build signal).

Specs load lib/*.js by evaluating the real file as a classic script
(test/helpers/loadLib.js) instead of adding module.exports shims to production
code that would never run in the browser. The suite therefore exercises exactly
the bytes packaged into the XPI.

Two packaging consequences, both handled:

- web-ext would otherwise bundle test/ and vitest.config.js INTO the XPI;
  both are now in --ignore-files across all four web-ext scripts.
- ci.yml's extension-version guard must ignore the same paths, or editing a
  spec would demand a pointless version bump. The dangerous drift direction is
  the opposite one -- a guard exclusion for a file that DOES ship would let a
  real change pass unnoticed -- so version.spec.js asserts every :(exclude) in
  ci.yml appears in --ignore-files.

extension.yml also triggers on ci.yml now, since version.spec.js reads it.

Refs #2393, #2397

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:47:18 -04:00
Claude c37a180c3c ci: guard the extension publish path against a missed version bump
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 2s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 45s
CI / integration (push) Successful in 3m59s
build.yml's sign-extension keys its AMO-signing cache purely on the version
string in extension/package.json. If an ext-<version> release already has an
XPI, signing is skipped and build-web bakes that OLD signed XPI into :latest.
Nothing in that path inspects whether extension/ actually changed, so a
forgotten bump ships a stale extension on a fully green build -- silently, and
as the default outcome of forgetting. AMO can't backstop it either: it 409s on
re-signing a version, which is precisely why the cache exists.

New extension-version job, pure git + text, no deps or services:

1. Unconditional consistency check. manifest.json and package.json versions
   must match. web-ext sign reads manifest.json (package.json is in
   --ignore-files and isn't even inside the XPI), so AMO signs the manifest
   version; build.yml keys its cache, release tag, XPI filename -- and so the
   version /api/extension/manifest reports to the update prompt -- on
   package.json. Divergence either 409s at AMO or ships an XPI whose update
   prompt lies about what's installed.

2. Changed-without-bump check. If any PACKAGED file under extension/ differs,
   the version must have moved. Exclusions mirror --ignore-files so a Renovate
   web-ext devDep bump in package.json doesn't falsely demand one.

Compared against main rather than the previous push: the publish decision is
made at merge-to-main against whatever ext-<version> exists, so "differs from
main" is the question that matters. Diffing against the previous dev push
would demand a fresh bump on every iteration, inflating the version to buy
nothing.

Bumping stays manual -- making it automatic requires rewriting the version in
CI and committing back to a protected branch, which this workflow deliberately
avoided. This only ensures a missed bump can no longer be silent.

Refs #2393

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:20:44 -04:00
Claude 8214afee1e fix(extension): normalize FC URL so credential push doesn't 405
CI / lint (push) Successful in 3s
extension / lint (push) Successful in 38s
CI / frontend-build (push) Successful in 40s
CI / backend-lint-and-test (push) Successful in 2m58s
CI / integration (push) Successful in 4m56s
The stored apiUrl was required to already carry the `/api` suffix, since
api.js builds requests as `${baseUrl}/credentials`. The options label read
"FC base URL", so entering the instance root -- the natural reading --
sent every request one path segment short: POST /credentials hit the Vue
SPA catch-all and came back 405, and GET /extension/manifest 404'd.

Worse, Test Connection reported success on it: the catch-all answers GET
/credentials with 200 HTML, so `r.ok` was true and the only affordance
meant to catch this misconfiguration actively masked it.

Normalize instead of validate (rules 92, 26):

- New lib/url.js: normalizeApiUrl / webRootFromApiUrl, one source shared
  by the background client and the options page. Accepts either the
  instance root or the API root.
- api.js normalizes on read, so configs already stored in the broken form
  heal themselves without the operator reopening Settings.
- options.js stores the canonical form, echoes back what it saved, and
  the test now asserts a JSON content-type -- killing the false green.
- 404/405 in request() now names the URL and points at the setting.
- Options label/placeholder state that both forms work.

Version 1.0.9 -> 1.0.10 in BOTH manifest.json and package.json; build.yml
resolves the release version from package.json, and a stale value there
would hit the cached ext-1.0.9 asset and republish the old XPI unsigned
against the new code.

Refs #2393

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:37:38 -04:00
bvandeusen 306de50f61 docs: Fabled-Git, not Forgejo, in ci-requirements
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 52s
CI / frontend-build (push) Successful in 25s
CI / integration (push) Successful in 4m14s
The instance has run Gitea since the migration. Also fixes a dead rulebook
pointer: the topic was renamed forgejo.md -> fabled-git.md, so the
"CI philosophy" reference pointed at a file that no longer exists.

Prose only — no workflow or path change. Scribe issue #2272.
2026-07-31 23:42:47 -04:00
bvandeusen b5b437ca80 Merge pull request 'feat(agent): idle-unload GPU models to free VRAM when the queue is idle' (#233) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 34s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 25s
Build images / build-web (push) Successful in 3m20s
Build images / build-ml (push) Successful in 4m29s
CI / integration (push) Successful in 4m26s
Build images / build-agent (push) Successful in 11m55s
2026-07-17 13:07:15 -04:00
bvandeusenandClaude Opus 4.8 57e52433d0 feat(agent): idle-unload GPU models to free VRAM when the queue is idle
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 38s
CI / integration (push) Successful in 4m3s
The SigLIP embedder + YOLO proposers load lazily then stay resident for the
container's whole lifetime — a 24/7 agent with an empty queue squats on ~5GB of
VRAM doing nothing (operator-observed: 4900MiB held at GPU-util 8% / P8). Sleep
mode only sheds downloaders + poll cadence; even a UI Stop left the models loaded.

Add a monitor thread that unloads the torch-owned models after
cfg.idle_unload_seconds (env IDLE_UNLOAD_SECONDS, default 300; 0 disables) with
the GPU genuinely idle (active==0, buffer drained, no job completed in the
window), then torch.cuda.empty_cache() to hand the blocks back to the driver.
They reload lazily on the next job via the existing _ensure_embedder /
_proposers_for. Covers both sleep-mode idle and a full Stop. Surfaced in
/status (models_loaded) and the agent UI pipe line; the VRAM meter drops too.

Residual: imgutils CCIP/person ONNX sessions + the CUDA context stay resident
(no clean unload API) — idle VRAM drops substantially, not to zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbrA36zNczjVhrM6cWThQa
2026-07-17 12:57:31 -04:00
bvandeusen ce0dac3524 Merge pull request 'DRY + stability pass — extension, ML/settings backend, frontend cards (#161)' (#232) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 12s
extension / lint (push) Successful in 11s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 50s
CI / integration (push) Successful in 3m47s
2026-07-13 23:08:18 -04:00
bvandeusenandClaude Opus 4.8 ec66ea5f83 refactor(ui): settings-card primitives + fix threshold clamp / card misgroup (#161)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m52s
extension / lint (pull_request) Successful in 10s
Tier-3 frontend DRY for the ML settings cards, plus the F-D2 clamp bug and the
F-D3 card misgrouping.

New primitives (components/common + composables):
- <SettingToggleRow> — the accent-icon + .fc-section-h label + right-aligned
  switch row (HeadsCard x3, CropProposersCard). iconColor prop absorbs the
  on/off dim.
- <SettingNumberField> — compact numeric field that CLAMPS to [min,max] on
  commit. This fixes F-D2: HeadsCard/CropProposersCard previously sent
  Number(raw) straight to the API, so an out-of-range threshold bounced off the
  400 validator (only TranslationCard clamped). density prop for the grid cards.
- useSettingSave(patchFn) — the busy + patch + toast + revert-on-failure flow
  each card hand-rolled (HeadsCard x6 handlers, CropProposersCard, MLBackfillCard,
  VideoEmbeddingCard). Returns ok/false for the optimistic-switch revert.

Adopted in HeadsCard, CropProposersCard, MLBackfillCard (handler only — its
plain labelled switch is a different affordance), VideoEmbeddingCard.

F-D3: MLThresholdSliders.vue actually rendered a "Video embedding" (frame-
sampling) card but sat under "Tagging → Suggestion thresholds". Renamed it
VideoEmbeddingCard.vue and moved it to the "GPU agent & embeddings" section.

Left deliberately (over-DRY guard): TranslationCard uses an inline error ALERT
(not a toast), already clamps its confidence with a NaN fallback, and lives on
the ImportStore — a genuinely different save pattern, so forcing it onto
useSettingSave would change its UX.

Behaviour-preserving refactor; CI has no Vue type-check so this needs a live
UI pass (toggles persist + revert on failure, thresholds clamp on blur, video
card now under Embeddings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi
2026-07-13 22:28:47 -04:00
bvandeusenandClaude Opus 4.8 e92570a31e refactor(extension): DRY the web-root transform + cookie-export flow (#161)
CI / lint (push) Successful in 2s
extension / lint (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m53s
Behavior-preserving (extension JS has lint-only CI + your manual test):
- api.webRoot() single-sources the baseUrl→web-root transform (strip trailing
  slash + /api) that was copy-pasted in background.js's self-update check and
  OPEN_ARTIST_PAGE, whose comments even cross-referenced each other.
- exportPlatformCookies(key) shares the extract→verify→upload spine between
  EXPORT_COOKIES (single) and EXPORT_ALL_COOKIES; it returns a structured
  outcome so each caller keeps its own response/skip messages verbatim.
- popup.js mutedNote(text) replaces the "centered muted note" div hand-rolled
  in the platform-loading, sources-loading, and empty-sources renderers.

No version bump — no behavior change, so it rides the next real ext release
rather than forcing an AMO re-sign + reinstall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi
2026-07-13 21:57:14 -04:00
bvandeusenandClaude Opus 4.8 a2d1ed935d refactor(ui): DRY the settings-card CSS tokens + fix unstyled headers (#161)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m57s
- Promote .fc-section-h to a global token (app.css). It was copied identically
  into 4 cards, and TranslationCard used the class with NO local def — so its
  section headers rendered unstyled. Now fixed everywhere.
- Promote .fc-good / .fc-weak status colours to globals; delete the local copies
  in the GPU/heads cards. (.fc-ok stays local — divergent: on-surface in
  HeadsCard vs success in QueuesTable. .fc-bad stays — different name.)
- Delete 10 identical local .fc-muted redefinitions that crept back after the
  2026-06-09 sweep; the global utility already covers them.
- DbMaintenanceCard: opacity:0.6 muted text → the .fc-muted token (the exact
  anti-pattern that token's comment forbids).
- HeadsCard: collapse byte-identical ratePct() into pct().

CSS-only + one template class swap; no logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi
2026-07-13 21:51:23 -04:00
bvandeusenandClaude Opus 4.8 05df51b749 fix(ml): drop unnecessary quotes on MLSettings.load annotations (UP037)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 28s
CI / backend-lint-and-test (push) Successful in 38s
CI / integration (push) Successful in 3m51s
Python 3.14 evaluates annotations lazily, so the self-referential return
type needs no forward-ref quotes — matches ImportSettings.load. Fixes the
ruff lint lane on the DRY-pass push.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi
2026-07-13 21:49:30 -04:00
bvandeusenandClaude Opus 4.8 099e1e664c refactor(patreon): DRY the campaigns-API request (#161)
CI / lint (push) Failing after 2s
CI / backend-lint-and-test (push) Successful in 28s
CI / frontend-build (push) Successful in 29s
CI / integration (push) Successful in 4m0s
_lookup_via_api and resolve_display_name shared ~90% of their body (same
endpoint, params, headers, error handling — differing only in which field
they pluck from data[0]). Extract _campaigns_api_first(vanity, cookies_path)
-> dict|None; callers pluck the campaign id vs the display name. Return-value
behavior preserved (the display-name path additionally gains the helper's more
granular warning logs). Covered by the existing test_patreon_resolver.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi
2026-07-13 21:41:34 -04:00
bvandeusenandClaude Opus 4.8 c87f8a1bb3 refactor(maintenance): DRY the recover_stalled head-run twins (#161)
recover_stalled_head_training_runs and recover_stalled_head_auto_apply_runs
were near-exact copies (coalesce-flip + keep-last-N prune, differing only in
model + two constants). Extract _recover_stalled_runs(model, stall_minutes,
keep_runs, label); the two tasks become thin wrappers. The other two recover
tasks are deliberately NOT folded in (library-audit has no prune tail; backup
uses a single started_at cutoff). test_recover_stalled_head_runs.py covers
both wrappers (stalled→error, fresh survives) — previously untested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi
2026-07-13 21:41:34 -04:00
bvandeusenandClaude Opus 4.8 666b3a2ec8 refactor(ml): DRY pass — shared sweep helpers + table-driven settings (#161)
Consolidate duplication accrued across the ML tagging + settings backend,
behavior-preserving (over-DRY guard applied — the three auto-apply sweep
BODIES stay separate; only their shared inner helpers are extracted).

- _sigmoid / _conflict_scores / _insert_presentation_review (heads.py): the
  score→prob transform (6 inlined sites), the presentation conflict signal
  (2 sites), and the ring-loud PresentationReview insert (2 sites, single-
  sourced so the mode column can't drift on the shared composite PK).
- _applied_or_rejected (training_data.py): the per-tag "applied ∪ rejected"
  skip-set, byte-identical at 3 sweep sites (heads.py x2, tasks/ml.py ccip).
- ccip sweep divergence fixes: import ccip._FIGURE_KINDS + training_data._l2norm
  instead of local copies that silently drift when the canonical changes.
- MLSettings.load / .load_sync classmethods (mirror ImportSettings); route all
  8 scalar_one singleton reads through them (the session.get None-path stays).
- GET serializers for MLSettings + ImportSettings are now table-driven off the
  same _EDITABLE tuples PATCH writes, so a new field can't be silently absent
  from GET (the split that historically dropped fields).
- AUTO_APPLY_THRESHOLD_MIN/MAX constant single-sources the [0.5,0.999] operating
  range across the service clamp + the 5 API validators.
- test_ml_dry_helpers.py pins _applied_or_rejected + _sigmoid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi
2026-07-13 21:41:24 -04:00
bvandeusen 9b5ec86222 Merge pull request 'feat(extension): in-app update prompt + v1.0.9 (#1489)' (#231) from dev into main
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
extension / lint (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 30s
Build images / sign-extension (push) Successful in 3m34s
Build images / build-web (push) Successful in 8s
CI / integration (push) Successful in 3m50s
2026-07-13 18:32:31 -04:00
bvandeusenandClaude Opus 4.8 d80a5255ed feat(extension): in-app update prompt — popup banner + toolbar badge (#1489)
CI / lint (push) Successful in 3s
extension / lint (push) Successful in 10s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 39s
CI / integration (push) Successful in 3m51s
extension / lint (pull_request) Successful in 10s
The extension is installed per-instance from the operator's FC host, so Firefox's
static update_url can't apply (each instance has a different host) and updates
were fully manual. Add a self-hosted-friendly update surface that reuses the
existing public GET /api/extension/manifest ({version, latest_url, sha256}):

- lib/api.js: getExtensionManifest().
- background.js: checkForUpdateInfo() compares the instance's latest published
  version against runtime.getManifest().version (dotted-numeric compare so
  1.0.10 > 1.0.9); CHECK_UPDATE message handler; refreshUpdateBadge() sets a
  toolbar badge via browser.action; a daily browser.alarms check plus on
  startup/installed. New 'alarms' permission (non-prompting).
- popup: an 'Update available — vX' banner with an Update button that opens the
  signed XPI (web root, /api stripped like OPEN_ARTIST_PAGE) → Firefox's native
  install prompt. Never blocks the popup on a failed check.

No backend changes (endpoint already exists). Bump 1.0.8→1.0.9 so this ships;
from here on updates surface themselves instead of needing a manual reinstall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:28:06 -04:00
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
bvandeusen 31d400ab0a Merge pull request 'Ship: title-based WIP auto-tagging (#1458)' (#222) from dev into main
Build images / sign-extension (push) Successful in 2s
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 9s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m50s
2026-07-12 19:45:03 -04:00
bvandeusenandClaude Opus 4.8 0da0e47784 fix(wip-title): count inserts via pre-SELECT, not driver rowcount
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m51s
apply_wip_image_tags relied on result.rowcount, but psycopg reports -1 for a
multi-row INSERT ... ON CONFLICT DO NOTHING (executemany path), so the return
count (and the backfill's reported total) was wrong. Compute the count from a
pre-SELECT of already-tagged ids within the same transaction; keep ON CONFLICT
DO NOTHING as a race-safety belt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:22:25 -04:00
bvandeusenandClaude Opus 4.8 503c8854bc style(importer): sort wip_title import into the local-import block
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 3m49s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 34s
Ruff I001 fixup for 5719387 — the new .wip_title import belongs after
.thumbnailer (alphabetical), not after .archive_extractor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:16:11 -04:00
bvandeusenandClaude Opus 4.8 571938781a feat(tagging): title-based WIP auto-tagging (#1458)
CI / lint (push) Failing after 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Failing after 3m50s
Auto-apply the `wip` system tag to posts whose TITLE explicitly declares
work-in-progress ("WIP" / "work in progress") — a deterministic, high-precision
complement to the image-based ML `wip` head. WIP images are excluded from the
Explore/gallery browse, so honouring the artist's own label keeps unfinished
pieces out of the main browse.

- services/wip_title.py: precision-first token-anchored matcher (swipe/wiped
  never trip it) + sync apply helpers (source='wip_title', ON CONFLICT DO
  NOTHING, chunked under the psycopg param ceiling).
- importer: live hook on FRESH import only (never on deep-scan/supersede), so a
  manually-removed WIP tag is never re-applied by a routine re-scan.
- maintenance.backfill_wip_title_tags: operator-triggered back-catalogue sweep
  (coarse SQL prefilter + regex confirm, keyset-paginated). Deliberately NOT a
  beat — a periodic re-run would silently undo manual removals.
- ImportSettings.wip_title_tagging_enabled (default ON, migration 0085) gating
  the live hook; GET/PATCH + POST /settings/wip-title/scan.
- Settings UI: toggle + "Scan existing posts" button.
- Tests: pure matcher unit tests + integration (apply idempotency, backfill
  precision).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:12:19 -04:00
bvandeusen d45ce5e426 Merge pull request 'Ship: Renovate onboarding + dependency modernization batches 1–3' (#221) 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 8s
extension / lint (push) Successful in 16s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 1m4s
Build images / build-web (push) Successful in 1m24s
Build images / build-ml (push) Successful in 2m36s
CI / integration (push) Successful in 3m46s
2026-07-11 23:16:33 -04:00
bvandeusen 77bfc32b02 Merge pull request 'feat(translation): tunable acceptance floor (0.90) + per-post sticky override (#155)' (#210) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 45s
CI / integration (push) Successful in 3m45s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 8s
2026-07-10 22:47:45 -04:00
bvandeusen d13efe1b69 Merge pull request 'refactor(tags): unify suggestion source — one canonical DB-tag dropdown (#154)' (#209) 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 13s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 3m44s
2026-07-10 15:52:44 -04:00
bvandeusen ca6d26d236 Merge pull request 'feat(translation): acceptance gate on Interpreter detection confidence (#1376)' (#208) 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 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m43s
2026-07-09 17:58:22 -04:00
bvandeusen 5366047d55 Merge pull request 'feat(translation): 8h sweep + drain button + detection probe (#1376)' (#207) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m50s
2026-07-08 20:19:33 -04:00
bvandeusen 2923257529 Merge pull request 'fix(ui): tag-chip left-edge clipping + kind-coloured border' (#206) 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 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m45s
2026-07-08 10:37:09 -04:00
bvandeusen 934731e9ef Merge pull request 'feat(translation): per-field language detection for mixed-language posts' (#205) 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 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m48s
2026-07-08 09:25:23 -04:00
bvandeusen 4c67c116b2 Merge pull request 'Bolder auto-tag accept/reject + larger tag chips' (#204) 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 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m50s
2026-07-07 23:21:26 -04:00
bvandeusen f8b667604f Merge pull request 'Graceful shutdown + resilience & Explore polish (m147)' (#203) 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 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m48s
2026-07-07 22:53:34 -04:00
bvandeusen 11572f469a Merge pull request 'Re-translate on Interpreter model change — artist-scoped + global (m146)' (#202) from dev into main
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 11s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m41s
2026-07-07 20:22:40 -04:00
bvandeusen ba3fd2a118 Merge pull request 'Post translation via Interpreter (milestone 143)' (#201) from dev into main
CI / lint (push) Successful in 3s
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 11s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m44s
2026-07-07 13:05:18 -04:00
bvandeusen 9bf095179a Merge pull request 'Auto-tag accept/reject as an in-pill yes/no pair; confirm refocuses the tag input' (#200) 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 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m44s
2026-07-07 01:13:02 -04:00
bvandeusen cefc064e0f Merge pull request 'Surface auto-hide misfires proactively (review strip no longer gated on Show-hidden)' (#199) 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 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m42s
2026-07-07 00:43:45 -04:00
bvandeusen 06757daf80 Merge pull request 'Presentation-chrome auto-hide (milestone 141) + tagging-UX fixes' (#198) 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 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m46s
2026-07-07 00:24:51 -04:00
bvandeusen a723ef436b Merge pull request 'Soft auto-apply (retract + confirm, no self-training) + tagging UX (reject-rest, tag-input race, modal playlist)' (#197) 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 11s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m46s
2026-07-06 21:03:30 -04:00
bvandeusen d68f39ca50 Merge pull request 'Suggestions perf + UX polish: incremental CCIP + head training, hover label, scroll/toast fixes, dead-preview removal' (#196) 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 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m43s
2026-07-06 16:41:08 -04:00
bvandeusen a66a695977 Merge pull request 'Tag-hover → crop-region grounding overlay (#1206, milestone #133)' (#195) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 17s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m51s
2026-07-06 13:29:31 -04:00
bvandeusen 5289fa3879 Merge pull request 'Crop-proposer detector config → Settings → Tagging (default-on, lease-announced) (#134)' (#194) 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 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m40s
2026-07-05 20:10:02 -04:00
bvandeusen ebac34e17b Merge pull request 'Artist identity/storage/display decoupling — rename, move-source, add-time name resolution (#130)' (#193) 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 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m40s
2026-07-04 22:43:12 -04:00
bvandeusen 4c3ba20198 Merge pull request 'Recapture image→post linking (#1288) + ugoira timings + system suggestion category' (#192) 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 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 3m36s
2026-07-04 20:36:58 -04:00
bvandeusen d2acec61ae Merge pull request 'Pixiv native ingester (#129) + SubscribeStar .art fix, disable-clears-failure, system-tag suggestion floor' (#191) 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 11s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 39s
CI / integration (push) Successful in 3m35s
2026-07-03 16:36:56 -04:00
bvandeusen 15ae5ef4fa Merge pull request 'SubscribeStar .art age fix, modal tag-flow sync, system tags / training hygiene (#128)' (#190) 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 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m34s
2026-07-03 09:16:16 -04:00
bvandeusen e3ceefc820 Merge pull request 'Surgical re-fetch for deep posts (ExternalLink reset) — recovery no longer needs deep scans' (#189) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 9s
Build images / build-agent (push) Successful in 14s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 38s
CI / backend-lint-and-test (push) Successful in 2m10s
CI / integration (push) Successful in 3m40s
2026-07-02 21:14:45 -04:00
bvandeusen e52090a4cf Merge pull request 'Agent sleep mode: empty queue sheds to one downloader, lease poll backs off to 15 min' (#188) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
Build images / build-web (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 42s
CI / integration (push) Successful in 3m50s
2026-07-02 20:37:48 -04:00
bvandeusen bfba8045e4 Merge pull request 'Admin IA overhaul, cap-aware autoscaler, optional ml-worker, hardened tag reset' (#187) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m29s
2026-07-02 18:01:56 -04:00
bvandeusen a708357436 Merge pull request 'Settings defaults, GPU error tombstones + failure triage/recovery, agent bandwidth cap, approved retirements' (#186) 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 10s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 38s
CI / integration (push) Successful in 3m33s
2026-07-02 15:48:48 -04:00
bvandeusen d9354ac1e1 Merge pull request 'Agent: short-video sampling fix + "Retry errored jobs" recovery action' (#185) 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 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
2026-07-01 21:13:48 -04:00
bvandeusen 1d48770793 Merge pull request 'Agent: fix Status freeze — conchint.textContent destroyed #capn (root cause)' (#184) from dev into main
Build images / sign-extension (push) Successful in 2s
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 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-07-01 20:46:28 -04:00
bvandeusen 489e6aaaee Merge pull request 'Agent: server-side throughput rates + killable-on-stop ffmpeg' (#183) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 9s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m26s
2026-07-01 20:01:00 -04:00
bvandeusen ed20df905b Merge pull request 'Agent: DRY pass + Status rate-metrics + real start/stop state machine' (#182) 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 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-07-01 16:30:32 -04:00
bvandeusen 5ba9871ef0 Merge pull request 'fix(agent): stream videos via ffmpeg-from-URL (no full download) — env-agnostic' (#181) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m27s
2026-07-01 14:20:53 -04:00
bvandeusen 2a820d0848 Merge pull request 'fix(agent): log the real fetch/submit failure reason (diagnose #1225)' (#180) 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 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m28s
2026-07-01 12:37:45 -04:00
bvandeusen 2f9aa3d86c Merge pull request 'perf(web): 4 MiB file streaming + 4 hypercorn workers (fix 40s downloads)' (#179) from dev into main
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 29s
Build images / sign-extension (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 6s
CI / integration (push) Successful in 3m26s
2026-07-01 11:55:35 -04:00
bvandeusen 560a5000a2 Merge pull request 'gallery: sort by earliest post date across all posts (new default)' (#178) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m26s
2026-07-01 10:54:41 -04:00
bvandeusen 7ddad231f8 Merge pull request 'agent: stop downloader pool stampeding a slow curator (congestion collapse)' (#177) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / integration (push) Successful in 3m22s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
2026-07-01 10:45:48 -04:00
bvandeusen a78f7eaace Merge pull request 'explore: more variance in the related rail (stronger MMR diversification)' (#176) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
2026-07-01 00:50:59 -04:00
bvandeusen 71337b0ba4 Merge pull request 'agent: temporal video dedup — drop near-duplicate frames before the GPU' (#175) 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 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-07-01 00:38:53 -04:00
bvandeusen 3996205f3b Merge pull request 'agent: dedupe near-duplicate crops before the SigLIP embed' (#174) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m26s
2026-07-01 00:24:39 -04:00
bvandeusen 9f9db01456 Merge pull request 'agent: download/GPU producer-consumer pipeline + detector fuse fix' (#173) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
2026-06-30 23:39:25 -04:00
bvandeusen 216b7fc743 Merge pull request 'Agent: bound video GPU work (early-exit frame loop at max_regions)' (#172) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
2026-06-30 22:57:33 -04:00
bvandeusen fbb76e6f36 Merge pull request 'Agent: huge-image load + figure/region caps + stale-active fix' (#171) from dev into main
Build images / sign-extension (push) Successful in 2s
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
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-06-30 22:47:48 -04:00
bvandeusen 5ef1478ade Merge pull request 'Modernise agent base → Ubuntu 24.04 / Python 3.12 / CUDA 12.9' (#170) from dev into main
Build images / build-ml (push) Failing after 3s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 3m22s
Build images / build-agent (push) Successful in 8m44s
2026-06-30 22:30:18 -04:00
bvandeusen 88ff4147e1 Merge pull request 'Fix: agent py3.10 startup crash + submit-path retry + pin agent ruff to py310' (#169) 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 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
2026-06-30 22:03:57 -04:00
bvandeusen 1ea02ad44c Merge pull request 'Release: prompt agent stop + lazy curator polling + build marker + agent in CI' (#168) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-web (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m23s
2026-06-30 21:42:55 -04:00
bvandeusen 937cfb65b4 Merge pull request 'Release: agent per-stage timing breakdown' (#167) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 5s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 3m23s
2026-06-30 21:28:57 -04:00
bvandeusen baac851220 Merge pull request 'perf: GPU-job leasing stays O(batch) — partial indexes + two-phase lease' (#166) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
CI / integration (push) Successful in 3m27s
CI / lint (push) Successful in 4s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-30 21:18:23 -04:00
bvandeusen a28c33281a Merge pull request 'Release: unfreeze agent status view + smoothed throughput autoscaler + log pane' (#165) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 9s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m23s
2026-06-30 20:20:34 -04:00
bvandeusen 40be0a9323 Merge pull request 'Release: agent page no-store cache header' (#164) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Failing after 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m23s
2026-06-30 20:00:36 -04:00
bvandeusen 2c6bf26bfc Merge pull request 'Release: graceful Start/Stop (starting/stopping states) + cached GPU reads' (#163) from dev into main
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 26s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / integration (push) Successful in 3m24s
2026-06-30 19:38:50 -04:00
bvandeusen 3c1e76bd44 Merge pull request 'Release: stable util-band autoscaler + live GPU meters' (#162) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 5s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 16s
Build images / build-web (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m44s
2026-06-30 19:16:29 -04:00
bvandeusen 831c5b1c10 Merge pull request 'Release: agent page — centered column + logs fill to bottom' (#161) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-06-30 19:10:50 -04:00
bvandeusen 76b6af4903 Merge pull request 'Release: agent perf (batched crop embeds) + UI (full-width, copy logs, quiet noise) + truncated-image fix' (#160) 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 6s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m32s
2026-06-30 18:47:55 -04:00
bvandeusen 92494ec4ed Merge pull request 'Release: agent control-page logs console + styling pass' (#159) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
CI / integration (push) Successful in 3m26s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-30 18:37:35 -04:00
bvandeusen bdfc17477c Merge pull request 'Release: GPU agent autoscaler (throughput hill-climb, Auto default-on)' (#158) 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
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m24s
2026-06-30 18:23:15 -04:00
bvandeusen 6cd3153bf4 Merge pull request 'Release: SigLIP 2 default (new installs) + embedder model dropdown' (#157) from dev into main
CI / lint (push) Successful in 3s
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 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m26s
2026-06-30 18:01:01 -04:00
bvandeusen 5f2853168a Merge pull request 'Release: GPU re-process trigger (apply new crop detectors to the existing library)' (#156) from dev into main
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m22s
Build images / build-agent (push) Successful in 3m35s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
2026-06-30 16:13:36 -04:00
bvandeusen 9a979ee808 Merge pull request 'Release: Explore diversification, model-swap, Camie/centroid retirement, tag-API, crop proposers' (#155) from dev into main
Build images / build-agent (push) Successful in 10m56s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m42s
CI / integration (push) Successful in 3m26s
2026-06-30 16:06:13 -04:00
bvandeusen 3138f912fd Merge pull request 'Release: SigLIP concept crops + max-over-bag + agent redeploy-survival' (#154) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
Build images / sign-extension (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 26s
Build images / build-web (push) Successful in 2m18s
Build images / build-ml (push) Successful in 2m44s
CI / integration (push) Successful in 3m35s
Build images / build-agent (push) Successful in 7m50s
2026-06-30 08:46:20 -04:00
bvandeusen 9df874e396 Merge pull request 'CCIP automation + reference quality (auto-enqueue, auto-apply, contamination fix, cache)' (#152) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 28s
CI / frontend-build (push) Successful in 20s
Build images / build-web (push) Successful in 2m20s
Build images / build-ml (push) Successful in 2m40s
Build images / build-agent (push) Successful in 3m17s
CI / integration (push) Successful in 3m27s
2026-06-29 22:29:30 -04:00
bvandeusen 6915a7590a Merge pull request 'fix(tags): refocus tag input after reject/un-reject too' (#151) from dev into main
CI / integration (push) Successful in 3m26s
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 7s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-29 21:07:21 -04:00
bvandeusen 5e8c28236a Merge pull request 'CCIP match threshold (tunable, 0.85) + Explore → variance' (#150) from dev into main
Build images / sign-extension (push) Successful in 2s
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 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m29s
2026-06-29 20:48:14 -04:00
bvandeusen 7e3c0f0b74 Merge pull request 'agent: raise worker cap to 32 + size HTTP pool' (#149) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 1s
CI / backend-lint-and-test (push) Successful in 26s
CI / frontend-build (push) Successful in 20s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 2m47s
Build images / build-agent (push) Successful in 3m11s
CI / integration (push) Successful in 3m31s
2026-06-29 19:42:11 -04:00
bvandeusen 5d0c7ba706 Merge pull request 'fix(agent): flatten transparency onto white before RGB' (#148) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m27s
2026-06-29 19:18:48 -04:00
bvandeusen 18300e1f8a Merge pull request 'Agent: GPU load + live worker tuning + fast orphan recovery' (#147) from dev into main
Build images / sign-extension (push) Successful in 2s
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 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m27s
2026-06-29 19:11:22 -04:00
bvandeusen d52ac0a0e2 Merge pull request 'fix(agent): cuDNN base image so onnxruntime-gpu loads' (#146) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 26s
Build images / build-web (push) Successful in 2m19s
Build images / build-ml (push) Successful in 2m50s
Build images / build-agent (push) Successful in 3m29s
CI / integration (push) Successful in 3m28s
2026-06-29 18:48:24 -04:00
bvandeusen 401fe8213e Merge pull request 'ci: publish the GPU agent image' (#145) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-agent (push) Successful in 3m15s
CI / integration (push) Successful in 3m27s
2026-06-29 14:26:51 -04:00
bvandeusen e8774d7953 Merge pull request 'CCIP characters + crop/region pipeline + desktop GPU agent (#114)' (#144) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / lint (push) Successful in 3s
Build images / build-web (push) Successful in 2m8s
Build images / build-ml (push) Successful in 2m37s
CI / integration (push) Successful in 3m26s
2026-06-29 14:18:57 -04:00
bvandeusen bc0f00c51b Merge pull request 'Earned auto-apply (fire + observability + UI), retrain cadences, Explore arrow-nav' (#143) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m30s
CI / integration (push) Successful in 3m24s
2026-06-29 07:30:41 -04:00
bvandeusen 1bef68aa29 Merge pull request 'Tagging-v2: heads are the suggestion source (learn-from-tags) + rail accept/reject' (#142) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m4s
Build images / build-ml (push) Successful in 2m44s
CI / integration (push) Successful in 3m25s
2026-06-28 11:55:49 -04:00
bvandeusen 1a4bc2f981 Merge pull request 'tag-eval: "keep" records a confirmation so doubts stop resurfacing' (#141) from dev into main
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
CI / integration (push) Successful in 3m20s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 2m30s
2026-06-28 01:36:26 -04:00
bvandeusen 862ace69d6 Merge pull request 'fix(tag-eval): stop re-suggesting already-rejected items' (#140) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m19s
2026-06-28 01:10:26 -04:00
bvandeusen abf88b1a15 Merge pull request 'tag-eval: auto-apply operating point + server-side top-N concept discovery' (#139) from dev into main
CI / backend-lint-and-test (push) Failing after 2s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m37s
CI / integration (push) Successful in 3m19s
2026-06-28 00:54:14 -04:00
bvandeusen c05dcafbea Merge pull request 'fix(tag-eval): example thumbnail opens the view modal instead of Explore' (#138) from dev into main
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 3m20s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-web (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-28 00:11:20 -04:00
bvandeusen 55e8632dab Merge pull request 'Tag-eval review actions: bigger clickable thumbnails + inline confirm/reject' (#137) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m36s
CI / integration (push) Successful in 3m18s
2026-06-27 23:49:12 -04:00
bvandeusen 825e6b90bf Merge pull request 'Tag-eval (heads vs centroid) + focus/provenance/layout/sticky-header fixes + tag-gaps cleanup' (#136) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m31s
CI / integration (push) Successful in 3m21s
2026-06-27 23:03:04 -04:00
bvandeusen fb012c557c Merge pull request 'fix(explore): bound 3-pane grid row so a tall rail can't scroll the page' (#135) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m37s
CI / integration (push) Successful in 3m20s
2026-06-26 23:37:43 -04:00
bvandeusen 66593ab895 Merge pull request 'Explore: focus-everywhere + provenance in rail; tag-gaps cleanup' (#134) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m17s
2026-06-26 21:40:56 -04:00
bvandeusen b266a54ad3 Merge pull request 'feat(explore): auto-focus tag input on every image change' (#133) 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-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m25s
2026-06-26 11:28:32 -04:00
bvandeusen ad803b646f Merge pull request 'fix(modal): place meta + save block under Provenance, above Tags' (#132) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m20s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 11s
2026-06-26 10:14:15 -04:00
bvandeusen 1f5da3d283 Merge pull request 'Fandom count fix + Explore 3-pane workspace + modal rail layout' (#131) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m21s
2026-06-26 08:24:26 -04:00
bvandeusen 93034f580d Merge pull request 'fix(tags): fandom views aggregate images via their characters' (#130) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m6s
Build images / build-ml (push) Successful in 2m34s
CI / integration (push) Successful in 3m19s
2026-06-26 00:28:21 -04:00
bvandeusen 9b9b12f410 Merge pull request 'Modal suggestions scroll-cap + Celery broker resilience' (#129) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Failing after 2m34s
CI / integration (push) Successful in 3m30s
2026-06-24 08:34:17 -04:00
bvandeusen 376d310693 Merge pull request 'Tagging & viewing roadmap: tag query surface + hygiene projections + Explore view (Clusters A/B/C)' (#128) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m6s
Build images / build-ml (push) Successful in 2m39s
CI / integration (push) Successful in 3m24s
2026-06-23 08:57:34 -04:00
bvandeusen bc69495a16 Merge pull request 'Provenance archive linkage, post reconciliation close-out, and the cleanup/admin DRY pass' (#127) from dev into main
Build images / build-web (push) Successful in 2m2s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-ml (push) Successful in 2m33s
CI / integration (push) Successful in 3m22s
2026-06-22 20:22:56 -04:00
bvandeusen 478f898e72 Merge pull request 'feat(settings): Maintenance tab compact tiles + centered Settings (pass 2)' (#126) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 37s
Build images / build-web (push) Successful in 2m18s
Build images / build-ml (push) Successful in 3m3s
CI / integration (push) Successful in 3m26s
2026-06-18 00:11:54 -04:00
bvandeusen 38a5e7f332 Merge pull request 'feat(settings): tidy Cleanup tab into sectioned compact tiles (pass 1)' (#125) from dev into main
CI / backend-lint-and-test (push) Failing after 2s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 3m19s
2026-06-17 23:49:21 -04:00
bvandeusen 57fe15c267 Merge pull request 'feat(maintenance): reconcile duplicate posts (gallery-dl→native unify)' (#124) 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 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m22s
2026-06-17 22:01:12 -04:00
bvandeusen eb3231ef10 Merge pull request 'fix(subscribestar): port gallery-dl date extraction (wrapped dates) + parse canary' (#123) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m19s
2026-06-17 21:15:43 -04:00
bvandeusen e9af459c0d Merge pull request 'feat(subscribestar): port gallery-dl doc + audio attachment extraction' (#122) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 9s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m15s
2026-06-17 16:20:12 -04:00
bvandeusen 6f02806aec Merge pull request 'fix(subscribestar): port gallery-dl content + preview-skip extraction (body bug)' (#121) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m25s
2026-06-17 16:10:56 -04:00
bvandeusen a1d19bd96a Merge pull request 'fix(subscribestar): mirror gallery-dl's full request profile (verify_subscriber gate)' (#120) 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-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m24s
2026-06-17 15:32:28 -04:00
bvandeusen 26827ff38f Merge pull request 'fix(subscribestar): match gallery-dl's generic post delimiter (live-feed drift)' (#119) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 38s
CI / integration (push) Successful in 3m15s
2026-06-17 15:07:59 -04:00
bvandeusen 26dcfaf6c2 Merge pull request 'fix(subscribestar): initial feed GET is a navigation, not XHR (first-run drift)' (#118) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m14s
2026-06-17 14:30:02 -04:00
bvandeusen 9b1b0369cc Merge pull request 'SubscribeStar → native core ingester + native-ingest DRY pass (milestone #71)' (#117) 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-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m20s
2026-06-17 12:48:29 -04:00
bvandeusen 18123fb9cb Merge pull request 'fix(external): recovery-sweep threshold + queue recording + split fetch timeouts (#883)' (#116) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m14s
2026-06-16 21:24:31 -04:00
bvandeusen 2e806f202f Merge pull request 'test(artist-dir): fix flaky uq_image_record_sha256 collision' (#115) 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-web (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m17s
2026-06-16 20:34:21 -04:00
bvandeusen 18d5c05639 Merge pull request 'fix(ml): per-task async engine for recompute_centroid (#881)' (#114) 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-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Failing after 3m19s
2026-06-16 20:24:37 -04:00
bvandeusen 11ddfc3876 Merge pull request 'fix(maint): resurface dedup/gated-purge results after navigate-away (#877)' (#113) from dev into main
Build images / sign-extension (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
Build images / build-web (push) Successful in 15s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m20s
2026-06-16 16:48:52 -04:00
bvandeusen 2b8ce86622 Merge pull request 'Gated Patreon posts: skip on ingest + cleanup tool (#874)' (#112) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m27s
2026-06-16 15:25:34 -04:00
bvandeusen 49bee77cdc Merge pull request 'Video tag quality: cadence sampling + min-frame aggregation + ML thread cap (#747)' (#111) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m15s
2026-06-16 14:08:40 -04:00
bvandeusen c209e3b37e Merge pull request 'Tier-1 video dedup: import-time + retroactive cleanup (#871)' (#110) from dev into main
CI / integration (push) Successful in 3m16s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 26s
2026-06-16 08:55:38 -04:00
bvandeusen cffdd93418 Merge pull request 'Nested-archive extraction (#718) + post-first ingest (#67) + post-body canary (#862)' (#109) from dev into main
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m19s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
2026-06-15 21:37:39 -04:00
bvandeusen fd84be40dd Merge pull request 'External-attach orphan fix (#859) + image-less post display' (#108) 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
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 40s
Build images / build-web (push) Successful in 10s
CI / integration (push) Successful in 3m19s
2026-06-15 01:55:36 -04:00
bvandeusen 79f510d7f8 Merge pull request 'Merge dev → main: read post body from content_json_string (the empty-body fix) (#842)' (#107) from dev into main
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m24s
Build images / sign-extension (push) Successful in 3s
2026-06-15 00:19:45 -04:00
bvandeusen 59181069da Merge pull request 'Merge dev → main: per-post stdout diagnostics + post-field write DRY (#842/#753)' (#106) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 41s
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 2m15s
Build images / build-ml (push) Successful in 2m55s
CI / integration (push) Successful in 3m11s
2026-06-14 23:45:39 -04:00
bvandeusen 428ecd8642 Merge pull request 'Merge dev → main: per-post body-capture diagnostics in the event UI (#842)' (#105) 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-web (push) Successful in 10s
CI / integration (push) Successful in 3m11s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 37s
2026-06-14 23:14:14 -04:00
bvandeusen ed1e04b831 Merge pull request 'Merge dev → main: recapture body-fetch fix + diagnostics (#842)' (#104) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m15s
2026-06-14 22:31:41 -04:00
bvandeusen f5156bd847 Merge pull request 'Merge dev → main: Recapture mode (#842) — re-grab post bodies/links + localize on-disk inline images' (#103) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 2m30s
CI / integration (push) Successful in 3m29s
Build images / build-ml (push) Successful in 3m27s
2026-06-14 21:21:49 -04:00
bvandeusen dfc3922d24 Merge pull request 'Merge dev → main: #830 rich post capture + external-host downloads (+ #768/#789/#739)' (#102) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 48s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m47s
CI / integration (push) Successful in 3m20s
2026-06-14 19:16:09 -04:00
bvandeusen 3eb08e926b Merge pull request 'fix(aliases): modal raw-key bug + alias visibility/management' (#101) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m13s
2026-06-12 14:02:00 -04:00
bvandeusen 9e81ced359 Merge pull request 'fix(images): percent-encode original-image URLs ('#' in paths 404'd)' (#100) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m15s
2026-06-12 00:44:56 -04:00
bvandeusen 11e9f5af60 Merge pull request 'fix(browse): tabs and search on one row' (#99) 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
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 11s
CI / integration (push) Successful in 3m13s
2026-06-12 00:28:38 -04:00
bvandeusen 909fa37b15 Merge pull request 'Browse search + series numbering rework + kebab fix' (#98) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 2m50s
CI / integration (push) Successful in 3m16s
2026-06-12 00:14:31 -04:00
bvandeusen dfab8f65ff Merge pull request 'feat(series): flat sequence + cosmetic dividers + pending staging (#789)' (#97) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-web (push) Successful in 2m13s
Build images / build-ml (push) Successful in 2m49s
CI / integration (push) Successful in 3m9s
2026-06-11 22:07:57 -04:00
bvandeusen 618f7cdc36 Merge pull request 'feat(ml): drop image_record.tagger_predictions — image_prediction is sole store (#768 step 3)' (#96) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m29s
Build images / build-ml (push) Successful in 3m0s
CI / integration (push) Successful in 3m13s
2026-06-11 19:32:05 -04:00
bvandeusen 028ea33a7c Merge pull request 'fix(migration): make 0045 DDL-only; backfill image_prediction via batched task (#768)' (#95) from dev into main
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-ml (push) Successful in 2m58s
CI / integration (push) Successful in 3m15s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 2m50s
2026-06-11 09:22:22 -04:00
bvandeusen 444c1fb075 Merge pull request 'perf(migration): 0045 streams json_each (no materialize / no temp blowup)' (#94) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 2m14s
Build images / build-ml (push) Successful in 2m36s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m8s
2026-06-10 22:07:16 -04:00
bvandeusen 26c68b0a75 Merge pull request 'fix(migration): 0045 guards json_each against scalar tagger_predictions' (#93) from dev into main
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m13s
Build images / sign-extension (push) Successful in 2s
Build images / build-web (push) Successful in 6s
Build images / build-ml (push) Successful in 2m40s
2026-06-10 20:33:27 -04:00
bvandeusen e75427b19a Merge pull request '#768 steps 1+2: normalized image_prediction table (read cutover)' (#92) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m4s
Build images / build-ml (push) Successful in 2m42s
CI / integration (push) Successful in 3m8s
2026-06-10 20:15:26 -04:00
bvandeusen 5447fab987 Merge pull request 'Activity search + RecoverySweep fix + tagger_predictions shrink (#762, #764) + backup polish' (#91) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-web (push) Successful in 2m23s
Build images / build-ml (push) Successful in 2m51s
CI / integration (push) Successful in 3m10s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
2026-06-10 14:33:35 -04:00
bvandeusen bad37e07b2 Merge pull request 'Browse hub, series rename, full-prediction dropdown + a DRY pass (7 sweeps)' (#90) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 27s
CI / frontend-build (push) Successful in 44s
Build images / build-ml (push) Successful in 3m2s
CI / integration (push) Successful in 3m15s
Build images / build-web (push) Successful in 2m17s
2026-06-10 00:24:01 -04:00
bvandeusen 2bfc9936a1 Merge pull request 'UI batch: tagging flow, series browse, fandom chips, nav' (#89) from dev into main
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 39s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 26s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m52s
CI / integration (push) Successful in 3m24s
2026-06-09 20:48:19 -04:00
bvandeusen 4c6406ee18 Merge pull request 'fix(posts): link duplicate items to every post + prune bare shells' (#88) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m19s
Build images / build-ml (push) Successful in 2m41s
CI / integration (push) Successful in 3m5s
2026-06-08 19:42:31 -04:00
bvandeusen bb47e80b3e Merge pull request 'fix(cleanup): unused-tags delete must use the same predicate as the preview' (#87) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m13s
2026-06-08 18:17:49 -04:00
bvandeusen dc1083b5e0 Merge pull request 'Unused-tag fandom fix + ML-worker logging/tuning + unified dropdown Enter' (#86) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m16s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 2m21s
2026-06-08 17:27:55 -04:00
bvandeusen e46893fefd Merge pull request 'Migration lock safety + remove the merge's full-table scan (the real 0040-hang fix)' (#85) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m33s
CI / integration (push) Successful in 3m7s
2026-06-08 01:03:06 -04:00
bvandeusen 0666e15211 Merge pull request 'Series manage redesign (FC-6.4) + migration/normalize hardening + UX fixes' (#84) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 1m58s
Build images / build-ml (push) Successful in 2m28s
CI / integration (push) Successful in 3m6s
2026-06-07 21:17:57 -04:00
bvandeusen 747390631d Merge pull request 'FC-6 series authoring + backup/NFS hardening + UX fixes' (#83) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m43s
CI / integration (push) Successful in 3m6s
2026-06-07 19:31:04 -04:00
bvandeusen e0d2a20588 Merge pull request 'Modal focus/keyboard polish, Camie-in-autocomplete, re-extract self-resume' (#82) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m32s
CI / integration (push) Successful in 3m9s
2026-06-07 12:17:43 -04:00
bvandeusen 1d84f67418 Merge pull request 'Modal: large centered spinner + kebab z-index fix' (#81) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m35s
CI / integration (push) Successful in 3m7s
2026-06-07 10:57:00 -04:00
bvandeusen 91265df3d6 Merge pull request 'Maintenance-queue health + modal/tagging keyboard pass' (#80) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m26s
CI / integration (push) Successful in 3m1s
2026-06-07 10:31:01 -04:00
bvandeusen 11acdb0322 Merge pull request 'Patreon: a missing media file_name is a fallback, not API drift' (#79) from dev into main
CI / lint (push) Successful in 2s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
Build images / sign-extension (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m2s
2026-06-06 23:14:25 -04:00
bvandeusen 2eb9fd5dd0 Merge pull request 'Patreon: enforce the backfill time-box mid-post (stop soft-limit overruns)' (#78) 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-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m3s
2026-06-06 23:00:29 -04:00
bvandeusen 01e5ce1410 Merge pull request 'Patreon: resolve creator campaign from a single-post URL' (#77) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m4s
2026-06-06 21:38:33 -04:00
bvandeusen 3bb94674cf Merge pull request 'Patreon download concurrency cap + immediate backfill kickoff on create' (#76) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
2026-06-06 21:27:22 -04:00
bvandeusen a75c602175 Merge pull request 'Subscriptions UX overhaul + Patreon /cw/ vanity fix' (#75) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m2s
2026-06-06 21:12:02 -04:00
bvandeusen ef8f4f7193 Merge pull request 'Tag-casing acronym fix, Patreon resolver hardening, archive diagnostics, post-card strip' (#74) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
2026-06-06 19:59:32 -04:00
bvandeusen ec3d27b219 Merge pull request 'Tag-maintenance sweep + bug-fix batch: #699 #700 #701 #709 #711 #712 #713 #714' (#73) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m2s
2026-06-06 16:49:00 -04:00
bvandeusen 03bd3b2eda Merge pull request 'Native Patreon ingester + download-engine ownership (plans #697, #703–#708)' (#72) from dev into main
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 32s
CI / integration (push) Successful in 3m1s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
2026-06-06 12:57:31 -04:00
bvandeusen 7395e77d75 Merge pull request 'Smarter backfill: time-boxed chunks, run-until-done (plan #693)' (#71) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 2m58s
2026-06-05 16:33:06 -04:00
bvandeusen 575d817919 Merge pull request '#70 dev→main: cursor-paged backfill + mobile modal fixes' 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
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 2m59s
2026-06-05 11:10:02 -04:00
bvandeusen 2a8f7cd8b6 Merge pull request '#69 dev→main: release v26.06.04.0' from dev into main
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 3m2s
Build images / sign-extension (push) Has been skipped
Build images / build-ml (push) Successful in 6s
Build images / build-web (push) Successful in 6s
2026-06-04 23:16:12 -04:00
bvandeusen 83f8af8090 Merge pull request 'dev→main: surface near-duplicate (pHash) control + reorder import tab' (#68) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 2m56s
2026-06-04 21:39:44 -04:00
bvandeusen 9a2617c1a2 Merge pull request 'dev→main: post-card redesign (images→modal, in-place text expand)' (#67) from dev into main
CI / lint (push) Failing after 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 24s
CI / integration (push) Successful in 2m57s
2026-06-04 17:32:50 -04:00
bvandeusen 81688815a0 Merge pull request 'dev→main: similar-search render fix + reset-content-tagging + scan persistence' (#66) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m58s
2026-06-04 16:59:52 -04:00
bvandeusen 773128c3bf Merge pull request 'dev→main: purpose-built mobile layout for subscriptions hub' (#65) 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 6s
CI / backend-lint-and-test (push) Successful in 14s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 38s
CI / integration (push) Successful in 2m56s
2026-06-04 13:43:56 -04:00
bvandeusen ce7b154ae9 Merge pull request 'dev→main: subscriptions table mobile card layout' (#64) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 14s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m57s
2026-06-04 12:56:58 -04:00
bvandeusen 9430a9d9c3 Merge pull request 'dev→main: gallery similarity search (Phase 3) + UI/mobile polish' (#63) 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
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / integration (push) Successful in 2m56s
2026-06-04 11:21:26 -04:00
bvandeusen 23aee56ce3 Merge pull request 'dev→main: showcase cascade + filter styling + DB maintenance + gallery filter Phase 2 + showcase decode-gate + CI perf' (#62) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 3m0s
2026-06-04 08:27:47 -04:00
bvandeusen 711abea567 Merge pull request 'Gallery speed + fandom editing + filters + pinned filter bar' (#61) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 18s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 11s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m29s
2026-06-04 00:07:19 -04:00
bvandeusen 844bb86802 Merge pull request 'fix(download): release DB connections across the gallery-dl subprocess' (#60) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 7m41s
CI / intcore (push) Successful in 8m24s
2026-06-03 22:11:36 -04:00
bvandeusen a8f6a464aa Merge pull request 'fix(download): salvage soft-time-limit kills + fix timeout ladder' (#59) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 21s
CI / frontend-build (push) Successful in 24s
Build images / build-web (push) Successful in 2m37s
Build images / build-ml (push) Successful in 3m21s
CI / intimp (push) Successful in 3m40s
CI / intapi (push) Successful in 7m47s
CI / intcore (push) Successful in 8m15s
2026-06-03 19:35:45 -04:00
bvandeusen ab9922ad2e Merge pull request 'feat(artist): "new since last visit" badge + banner' (#58) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 23s
Build images / build-ml (push) Successful in 2m53s
CI / frontend-build (push) Successful in 21s
Build images / build-web (push) Successful in 2m9s
CI / intimp (push) Successful in 3m36s
CI / intapi (push) Successful in 7m32s
CI / intcore (push) Successful in 8m9s
2026-06-03 16:20:54 -04:00
bvandeusen 0533807669 Merge pull request 'feat(ext): verify cookies in-browser before uploading (1.0.7)' (#57) from dev into main
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-ml (push) Successful in 3m28s
Build images / build-web (push) Successful in 3m42s
CI / intapi (push) Successful in 9m32s
CI / frontend-build (push) Successful in 20s
extension / lint (push) Successful in 14s
Build images / sign-extension (push) Successful in 3m17s
CI / intimp (push) Successful in 4m26s
CI / lint (push) Successful in 3s
CI / intcore (push) Successful in 10m18s
2026-06-03 14:17:42 -04:00
bvandeusen 279dff3fb6 Merge pull request 'feat(ml): normalize Camie suggestion names to human-readable' (#56) from dev into main
Build images / build-ml (push) Successful in 2m55s
CI / intimp (push) Successful in 3m36s
CI / intapi (push) Successful in 7m38s
CI / intcore (push) Successful in 8m24s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 2m17s
2026-06-03 13:18:44 -04:00
bvandeusen 37e66cddc4 Merge pull request 'chore(modal): drop ?image=N soft-compat — pure overlay' (#55) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-ml (push) Successful in 2m46s
Build images / build-web (push) Successful in 2m36s
CI / intimp (push) Successful in 3m42s
CI / intapi (push) Successful in 7m25s
CI / intcore (push) Successful in 8m12s
2026-06-02 19:35:04 -04:00
bvandeusen 9cf6b2d363 Merge pull request 'audit-g5 final + ML threshold default + kebab menu fix' (#54) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 27s
Build images / build-web (push) Successful in 3m0s
Build images / build-ml (push) Successful in 3m45s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 8m8s
CI / intcore (push) Successful in 8m34s
2026-06-02 19:09:49 -04:00
bvandeusen 6ef0fed41f Merge pull request 'audit-g5: architectural debt — 4 bundles (A/B/C/D)' (#53) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 18s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 26s
CI / intimp (push) Successful in 3m51s
CI / intapi (push) Successful in 7m38s
CI / intcore (push) Successful in 8m52s
2026-06-02 18:07:25 -04:00
bvandeusen 89b48f8f35 Merge pull request 'audit-g4: status-enum miss batch' (#52) 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 11s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 35s
CI / intimp (push) Successful in 3m41s
CI / intapi (push) Successful in 7m25s
CI / intcore (push) Successful in 8m10s
2026-06-02 16:15:00 -04:00
bvandeusen d60e0b9494 Merge pull request 'audit-g3: lifecycle batch — recovery sweeps, retention, timeouts' (#51) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 36s
CI / intimp (push) Successful in 3m39s
CI / intapi (push) Successful in 7m39s
CI / intcore (push) Successful in 8m10s
2026-06-02 14:49:28 -04:00
bvandeusen 9c27a2d3c7 Merge pull request 'audit-g2: async race / state-leak fixes across eight stores' (#50) 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
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 37s
Build images / build-web (push) Successful in 13s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 8m15s
CI / intcore (push) Successful in 8m50s
2026-06-02 14:17:12 -04:00
bvandeusen 93e37681b7 Merge pull request 'audit-g1: six one-liner drift fixes from 2026-06-02 audit' (#49) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 11s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 10s
CI / intapi (push) Successful in 7m28s
CI / intcore (push) Successful in 8m3s
CI / intimp (push) Successful in 3m42s
2026-06-02 13:29:17 -04:00
bvandeusen 64ca858574 Merge pull request 'UX fixes: suggestion-accept chip refresh, showcase endless feed, non-media downloads' (#48) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 20s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m20s
2026-06-02 08:26:58 -04:00
bvandeusen 9d0c0b7da8 Merge pull request 'fix(thumbnails): surface backfill counts + tighten validity check' (#47) 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-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 18s
CI / intimp (push) Successful in 3m45s
CI / intapi (push) Successful in 7m32s
CI / intcore (push) Successful in 8m4s
2026-06-01 22:34:38 -04:00
bvandeusen 8e4d252ae4 Merge pull request 'fix(downloads): enqueue thumbnail + ML per attached image' (#46) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / intimp (push) Successful in 3m40s
CI / lint (push) Successful in 3s
CI / intapi (push) Successful in 7m51s
CI / intcore (push) Successful in 8m46s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 19s
CI / frontend-build (push) Successful in 26s
2026-06-01 21:52:42 -04:00
bvandeusen fdd3e01f56 Merge pull request 'ux(failing-sources): visible row separators + clearer hover' (#45) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 10s
CI / backend-lint-and-test (push) Successful in 20s
CI / frontend-build (push) Successful in 33s
CI / intimp (push) Successful in 3m44s
CI / intapi (push) Successful in 7m48s
CI / intcore (push) Successful in 8m16s
2026-06-01 21:09:28 -04:00
bvandeusen c82fb308b6 Merge pull request 'Post.source_id refactor + tick/backfill modes + PARTIAL classifier + Mux fix + UX' (#44) from dev into main
Build images / build-ml (push) Failing after 2s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / intimp (push) Successful in 3m47s
CI / intapi (push) Successful in 7m52s
CI / intcore (push) Successful in 8m16s
Build images / build-web (push) Successful in 12s
CI / backend-lint-and-test (push) Successful in 19s
CI / frontend-build (push) Successful in 31s
2026-06-01 20:44:23 -04:00
bvandeusen 8cf8d2ca4d Merge pull request 'Modal Esc/overflow polish, artist-scoped post scroll, failing-sources Logs button' (#43) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 2m45s
Build images / build-ml (push) Successful in 3m31s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m11s
CI / intcore (push) Successful in 7m47s
2026-06-01 12:10:11 -04:00
bvandeusen b1d58bc3b8 Merge pull request 'fix(ci): POSIX-safe SHORT_SHA in build.yml' (#42) from dev into main
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 20s
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 3m24s
CI / intimp (push) Successful in 3m22s
CI / intapi (push) Successful in 7m51s
CI / intcore (push) Successful in 8m5s
2026-06-01 08:03:17 -04:00
bvandeusen 65386f02a0 Merge pull request 'View modal batch: autofocus, suggestions UX, post-title click, retire copyright/artist' (#41) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Failing after 4s
Build images / build-web (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 17s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 7m17s
CI / intcore (push) Successful in 8m2s
2026-06-01 07:01:42 -04:00
bvandeusen 667b05f14e Merge pull request 'Extension probe-and-add (v1.0.6) + per-commit image tags' (#40) from dev into main
CI / lint (push) Successful in 2s
Build images / build-ml (push) Failing after 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 22s
extension / lint (push) Successful in 8s
Build images / sign-extension (push) Successful in 1m51s
Build images / build-web (push) Failing after 4s
CI / intimp (push) Successful in 3m38s
CI / intapi (push) Successful in 7m20s
CI / intcore (push) Successful in 7m45s
2026-06-01 01:44:03 -04:00
bvandeusen 856e9104b4 Merge pull request 'Sidecar synthetic anchor cleanup + tier-gated classifier fix' (#39) from dev into main
CI / lint (push) Successful in 5s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
CI / intimp (push) Successful in 3m54s
Build images / sign-extension (push) Has been skipped
Build images / build-web (push) Successful in 1m1s
Build images / build-ml (push) Successful in 1m21s
CI / intapi (push) Successful in 7m31s
CI / intcore (push) Successful in 8m0s
2026-06-01 00:16:58 -04:00
bvandeusen 0397642b21 Merge pull request 'Showcase cadence tuning + cooldown-aware bulk retry' (#38) from dev into main 2026-05-30 23:50:36 -04:00
bvandeusen 237575447d Merge pull request 'Thumbnail URL fix + archive daemon fix + batched initial loads' (#37) from dev into main 2026-05-30 22:01:43 -04:00
bvandeusen ed358757dc Merge pull request 'Most-overdue-first scheduling + rich timeout diagnostics' (#36) from dev into main 2026-05-30 14:30:41 -04:00
bvandeusen d181f4afb8 Merge pull request 'Downloads burst-prevention + maintenance-menu fix + gdl timeout' (#35) from dev into main 2026-05-30 11:43:18 -04:00
bvandeusen 2886fa4997 Merge pull request 'Tooltip !important fix — 104cac5 follow-up after Vite CSS reorder' (#34) from dev into main 2026-05-30 00:02:24 -04:00
bvandeusen f256f587ee Merge pull request 'UI batch + I1–I6 service passes + download-event recovery sweep' (#33) from dev into main 2026-05-29 22:46:16 -04:00
bvandeusen 384d8d5e50 Merge pull request 'Dashboard insights + project-wide DRY pass' (#32) from dev into main 2026-05-28 15:38:26 -04:00
bvandeusen 319e8c1d18 Merge pull request 'v26.05.28.0: downloads dashboard + task-resilience overhaul (timeouts, archive split, 3-layer poison-pill defense)' (#31) from dev into main 2026-05-28 00:45:00 -04:00
bvandeusen 9075d8eadd Merge pull request 'v26.05.27.2: subscribestar + HF cookie quirks, platforms package refactor, showcase IR-parity, secure-context audit' (#30) from dev into main 2026-05-27 21:34:02 -04:00
bvandeusen 88e53e5b86 Merge pull request 'v26.05.27.1: subscriptions hub + post-card merge + sidecar audit' (#29) from dev into main 2026-05-27 17:12:48 -04:00
bvandeusen 37e8b796a1 Merge pull request 'v26.05.27.0: PostCard redesign + IR-style tag suffix + drop meta/rating + extension v1.0.4 CSP fix' (#28) from dev into main 2026-05-27 11:31:18 -04:00
bvandeusen 4e82208926 Merge pull request 'v26.05.26.5 — extension CORS unblock + UI gap closes + CI workflow cleanup' (#27) from dev into main 2026-05-26 20:15:07 -04:00
bvandeusen 52fff00353 Merge pull request 'v26.05.26.4 — hotfix: migration 0022 pre-DELETE colliding ImageProvenance before UPDATE' (#26) from dev into main 2026-05-26 18:06:20 -04:00
bvandeusen c14338cbce Merge pull request 'v26.05.26.3 — hotfix: migration 0022 pre-merge across ENTIRE (canonical+others) group' (#25) from dev into main 2026-05-26 17:52:59 -04:00
bvandeusen 8c36dd28b0 Merge pull request 'v26.05.26.2 — hotfix: alembic 0022 Post-collision pre-merge + ci.yml cache continue-on-error' (#24) from dev into main 2026-05-26 16:50:43 -04:00
bvandeusen 88cfb3dd02 Merge pull request 'v26.05.26.1 — thumb backfill, modal redesign, recovery sweep race-safety, artist view redesign, extension fixes' (#23) from dev into main 2026-05-26 16:32:00 -04:00
bvandeusen 5d4f223b71 Merge pull request 'Release v26.05.25.7 — FC-Cleanup tab + UniqueViolation fix + error modal + extension install fix' (#22) from dev into main 2026-05-26 08:26:46 -04:00
bvandeusen 05090c6e85 Merge pull request 'Release v26.05.25.7 — animated-WebP worker fix + FC-Cleanup backend' (#21) from dev into main 2026-05-26 01:48:13 -04:00
bvandeusen 3a577d5ade Merge pull request 'fix(ext-ci): use browser_download_url + curl -f + ZIP magic check (XPI silently corrupt)' (#20) from dev into main 2026-05-26 00:43:02 -04:00
bvandeusen f4fe02e346 Merge pull request 'fix(ext-ci): drop actions/upload-artifact (Forgejo doesn't support v4+ GHES)' (#19) from dev into main 2026-05-25 23:33:40 -04:00
bvandeusen e766197d99 Merge pull request 'fix(ext-ci): jq→python + bump ext to 1.0.3 + rollback-on-upload-failure' (#18) from dev into main 2026-05-25 23:14:51 -04:00
bvandeusen 3872e1dda9 Merge pull request 'fix(ext-ci): web-ext v8 .cjs config workaround' (#17) from dev into main 2026-05-25 22:49:14 -04:00
bvandeusen 9814f3dbaf Merge pull request 'Release v26.05.25.5 — Extension publish refactor, deep-scan IR-parity, archive-import perf, artist Settings tab' (#16) from dev into main 2026-05-25 22:44:59 -04:00
bvandeusen b214460fdb Merge pull request 'Release v26.05.25.4 — importer ext sanitize fix, CI shard split, BrowserExtensionCard on Overview' (#15) from dev into main 2026-05-25 21:11:50 -04:00
bvandeusen ac55d0e8d8 Merge pull request 'fix(ext-ci): match AMO-renamed signed XPI' (#14) from dev into main 2026-05-25 18:22:50 -04:00
bvandeusen 89a89e0ded Merge pull request 'Release v26.05.25.3 — ML embedder SigLIP fix, import-UX, extension publish' (#13) from dev into main 2026-05-25 17:56:50 -04:00
bvandeusen 4e9aac2c05 Merge pull request 'v26.05.25.2: supersede + sidecar enrichment, scan toast feedback, CI uv + pip cache + durations' (#12) from dev into main 2026-05-25 14:30:25 -04:00
bvandeusen 2879ac6f2b Merge pull request 'v26.05.25.1: maintenance sweep + Camie v2 + corrupt-file handling + post-date gallery + clear-stuck escape hatch' (#11) from dev into main 2026-05-25 12:57:46 -04:00
bvandeusen b8dce6c483 Merge pull request 'FC-3h + FC-3k: backup first-class + admin destructive actions' (#10) from dev into main 2026-05-25 01:41:53 -04:00
bvandeusen d1c0b82a22 Merge pull request 'v26.05.24.3: FC-3i System Activity dashboard + migration backup-gate retired + modal Escape' (#9) from dev into main 2026-05-24 21:47:53 -04:00
bvandeusen 5526b8dc78 Merge pull request 'v26.05.24.2: IR Post/Provenance restore + modal artist fallback' (#8) from dev into main 2026-05-24 14:30:06 -04:00
bvandeusen 16eb7075c4 Merge pull request 'v26.05.24.1: FC-3g Firefox extension + worker resilience + UI/migration fixes' (#7) from dev into main 2026-05-24 12:52:31 -04:00
bvandeusen 885dcf64f3 Merge pull request 'v26.05.24.0: TopNav re-fix (flex 1 1 0 side cells)' (#6) from dev into main 2026-05-23 22:49:29 -04:00
bvandeusen f2f6b6d25e Merge pull request 'v26.05.23.3: dogfood UX polish + accurate active-batch stats' (#5) from dev into main 2026-05-23 22:05:59 -04:00
bvandeusen 0822240fde Merge pull request 'v26.05.23.2: serve /images + artist cleanup migrator' (#4) from dev into main 2026-05-23 12:19:16 -04:00
bvandeusen 27f7f3fd01 Merge pull request 'v26.05.23.1: migration durability + dogfood UX' (#3) from dev into main 2026-05-23 11:21:33 -04:00
bvandeusen c5bf564f53 Merge dev: v26.05.23.0 migration follow-ups (#2)
pg_dump + zstd in runtime image, lift Quart body cap to 1 GiB. See PR #2.
2026-05-22 22:37:06 -04:00
bvandeusen 602c7d275d Merge dev: FC-1 → FC-5 v1 build (#1)
First merge of `dev` into `main` for FabledCurator. Brings FC-1 (Foundation) through FC-5 (Migration tooling) onto `main`. See PR #1 body for the full stage rollup.
2026-05-22 14:15:45 -04:00
91 changed files with 3079 additions and 685 deletions
+116
View File
@@ -2,6 +2,7 @@ name: CI
# CI lanes per FabledRulebook/forgejo.md "CI philosophy":
# - lint: ruff only, no dep install — fast-fail for the common lint bounce.
# - extension-version: guards the extension publish path (see the job).
# - backend-lint-and-test: `pytest -m "not integration"`, no service containers.
# - frontend-build: vitest unit + vite build.
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
@@ -41,6 +42,121 @@ jobs:
# catching syntax errors before the image build.
run: python -m compileall -q agent/fc_agent
# Guards the extension publish path, which has no self-correcting behavior.
#
# build.yml's sign-extension job keys its AMO-signing cache purely on the
# version string in extension/package.json: if an `ext-<version>` Forgejo
# release already carries an XPI, signing is SKIPPED and that old signed XPI
# is what build-web bakes into `:latest`. Nothing in that path inspects
# whether extension/ actually changed — so a forgotten version bump ships a
# stale extension on a fully green build, silently. (AMO can't help: it 409s
# on re-signing a version, which is exactly why the cache exists.)
#
# This job makes that case loud, on the dev push, instead of invisible at
# merge-to-main. It is pure git + text work — no deps, no services.
extension-version:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v4
with:
# Full history: the check diffs against the push's `before` SHA (or
# the PR base), which a depth-1 clone wouldn't contain.
fetch-depth: 0
- name: Extension version guard
env:
BEFORE: ${{ github.event.before }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
run: |
set -eu
# busybox sh on the act_runner — no bashisms (family rule).
ver() { grep -E '"version"' "$1" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/'; }
PKG=$(ver extension/package.json)
MAN=$(ver extension/manifest.json)
test -n "$PKG" || { echo "ERROR: no version found in extension/package.json"; exit 1; }
test -n "$MAN" || { echo "ERROR: no version found in extension/manifest.json"; exit 1; }
# (1) Unconditional: the two version strings must agree. `web-ext sign`
# reads manifest.json (package.json sits in --ignore-files and isn't
# even inside the XPI), so AMO signs MAN and Firefox installs MAN.
# build.yml keys its cache, release tag, XPI filename — and therefore
# the version /api/extension/manifest reports to the update prompt —
# on PKG. Divergence either hard-fails at AMO or ships a mislabelled
# XPI whose update prompt lies about what's installed.
if [ "$MAN" != "$PKG" ]; then
echo "ERROR: extension version mismatch."
echo " extension/manifest.json = $MAN <- what AMO signs / Firefox installs"
echo " extension/package.json = $PKG <- what CI caches, names, and reports"
echo "Set both to the same value."
exit 1
fi
# (2) If the SHIPPED extension changed, the version must have moved.
#
# Compare against MAIN, not against the previous push. The publish
# decision is made at merge-to-main against whatever ext-<version>
# already exists, so "differs from main" is the question that matters.
# Diffing against the previous dev push instead would demand a fresh
# bump on every iteration — push, tweak the extension again, and CI
# would insist on a second bump that buys nothing, inflating the
# version for no reason. On a main push there is no "main to compare
# to" yet, so fall back to that push's own before-SHA.
if [ "${GITHUB_REF##*/}" = "main" ]; then
BASE="${BEFORE:-}"
else
BASE=$(git rev-parse --verify -q origin/main 2>/dev/null || git rev-parse --verify -q main 2>/dev/null || echo "")
# PR base is the fallback when main isn't in the clone at all.
[ -n "$BASE" ] || BASE="${PR_BASE:-}"
fi
case "$BASE" in
''|0000000000000000000000000000000000000000)
echo "No usable base ref (no main in clone / first push) — skipping the bump check."
echo "OK: extension version $PKG"
exit 0
;;
esac
if ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
echo "Base commit $BASE not in this clone — skipping the bump check."
echo "OK: extension version $PKG"
exit 0
fi
# Exclusions mirror --ignore-files in extension/package.json's web-ext
# scripts: these files are not packaged into the XPI, so touching them
# (e.g. Renovate bumping the web-ext devDep, or editing a spec)
# changes nothing shipped and must not demand a version bump.
# KEEP IN SYNC with --ignore-files — a file packaged into the XPI but
# excluded here is exactly the silent-stale-ship this job exists to
# prevent. test/version.spec.js pins the two lists' shared intent.
CHANGED=$(git diff --name-only "$BASE" HEAD -- extension/ \
':(exclude)extension/package.json' \
':(exclude)extension/package-lock.json' \
':(exclude)extension/README.md' \
':(exclude)extension/.gitignore' \
':(exclude)extension/vitest.config.js' \
':(exclude)extension/test/**')
if [ -z "$CHANGED" ]; then
echo "No packaged extension files changed since $BASE — nothing to guard."
echo "OK: extension version $PKG"
exit 0
fi
echo "Packaged extension files changed since $BASE:"
echo "$CHANGED" | sed 's/^/ /'
PKG_OLD=$(git show "$BASE:extension/package.json" 2>/dev/null | grep -E '"version"' | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
if [ -z "$PKG_OLD" ]; then
echo "Could not read the base version — skipping the bump check."
echo "OK: extension version $PKG"
exit 0
fi
if [ "$PKG_OLD" = "$PKG" ]; then
echo "ERROR: packaged extension files changed but the version is still $PKG."
echo "build.yml would find the existing ext-$PKG release, skip AMO signing,"
echo "and bake the OLD signed XPI into :latest — a green build shipping stale code."
echo "Bump the version in BOTH extension/package.json and extension/manifest.json."
exit 1
fi
echo "OK: extension version $PKG_OLD -> $PKG"
backend-lint-and-test:
runs-on: python-ci
container:
+14 -3
View File
@@ -1,5 +1,5 @@
name: extension
# Lint-only workflow. The sign-and-publish dance moved into build.yml's
# Lint + unit tests. The sign-and-publish dance moved into build.yml's
# `sign-extension` job (2026-05-25) — `:latest` now always bundles the XPI
# because sign-extension runs as a build-web dependency in the SAME workflow,
# eliminating the prior race between build.yml and a separate extension.yml.
@@ -10,10 +10,15 @@ on:
paths:
- 'extension/**'
- '.forgejo/workflows/extension.yml'
# test/version.spec.js asserts ci.yml's extension-version guard never
# ignores a file web-ext actually packages, so a ci.yml-only edit can
# break this suite and must trigger it.
- '.forgejo/workflows/ci.yml'
pull_request:
branches: [main]
paths:
- 'extension/**'
- '.forgejo/workflows/ci.yml'
workflow_dispatch:
jobs:
@@ -23,7 +28,13 @@ jobs:
image: node:24-bookworm-slim
steps:
- uses: actions/checkout@v4
- name: Install web-ext
run: cd extension && npm install --no-save --no-audit --no-fund
# Not --no-save: vitest and web-ext are both real devDependencies now,
# and the suite needs vitest resolvable from node_modules.
- name: Install dev dependencies
run: cd extension && npm install --no-audit --no-fund
- name: Lint
run: cd extension && npm run lint
# Pure-logic specs over lib/url.js and lib/platforms.js plus manifest /
# package version-consistency checks. No browser, no network.
- name: Unit tests
run: cd extension && npm run test:unit
+4 -1
View File
@@ -21,7 +21,7 @@ log = logging.getLogger("fc_agent.app")
# Bump on every agent change. The page embeds this and /status reports it; the UI
# warns to reload when they differ — so a stale browser-cached page can't be
# mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.)
VERSION = "2026-07-02.6 · sleep mode: an empty queue sheds to one downloader and backs the lease poll off to 15 min"
VERSION = "2026-07-17.1 · idle model-unload: after ~5 min idle the GPU models release their VRAM and reload on the next job (env IDLE_UNLOAD_SECONDS, 0=off) · sleep mode sheds to one downloader"
logbuf.install()
cfg = Config.from_env()
@@ -334,9 +334,12 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
waited.textContent=s.transient||0
// Instantaneous pool state → demoted to the sub-line, where its jumpiness reads
// as live churn rather than a "broken" headline metric.
// '=== false' (not falsy) so a stale page that doesn't send models_loaded shows
// nothing; when the idle monitor unloads, the VRAM meter drops alongside this.
pipe.textContent='downloaders '+(s.downloaders!=null?s.downloaders:'')+' · consumers '+(s.consumers!=null?s.consumers:'')+' · on GPU '+(s.active||0)
+' · net '+(s.net_mb_s!=null?s.net_mb_s.toFixed(1):'')+' MB/s'
+(s.bandwidth_limit_mb_s>0?(' / cap '+s.bandwidth_limit_mb_s):'')
+(s.models_loaded===false?' · GPU models unloaded (idle — reload on next job)':'')
if(document.activeElement!==bw && s.bandwidth_limit_mb_s!=null) bw.value=s.bandwidth_limit_mb_s
// Buffer occupancy bar (also driven here so it tracks the /status cadence).
if(s.buffer!=null && s.buffer_max){ const p=Math.round(100*s.buffer/s.buffer_max)
+10
View File
@@ -51,6 +51,12 @@ class Config:
bandwidth_limit_mb_s: float # aggregate download cap in MEGABYTES/s across
# all downloaders + video streams (0 = unlimited);
# tunable live from the agent UI
idle_unload_seconds: float # after this long with the GPU idle (nothing in
# flight, queue empty or Stopped), unload the
# SigLIP embedder + YOLO proposers to free their
# VRAM; they reload lazily on the next job. A
# 24/7 agent otherwise squats on ~5GB doing
# nothing. 0 disables (keep models warm forever).
@classmethod
def from_env(cls) -> Config:
@@ -87,4 +93,8 @@ class Config:
# link to ~1-1.5 MB/s per stream, browser included). Raise it (or 0)
# from the agent UI on wired/faster networks.
bandwidth_limit_mb_s=float(os.environ.get("BANDWIDTH_LIMIT_MB_S", "8")),
# 5 min: long enough that a lull between job bursts doesn't thrash the
# (few-second) reload, short enough that an agent left running with an
# empty queue hands its VRAM back promptly.
idle_unload_seconds=float(os.environ.get("IDLE_UNLOAD_SECONDS", "300")),
)
+15
View File
@@ -170,6 +170,13 @@ class YoloProposer:
))
return out
def unload(self) -> None:
"""Drop the loaded YOLO so its VRAM can be reclaimed; detect() reloads it
lazily on the next job. Leaves _ok untouched — a healthy proposer comes
back, but one that self-disabled on a fault stays off."""
with self._lock:
self._model = None
class Proposers:
"""The agent's proposer set, built from config. Each detector is optional —
@@ -216,3 +223,11 @@ class Proposers:
def panels(self, image):
return self._top(self._panel, image, self.cfg.max_panels)
def unload(self) -> None:
"""Release every loaded proposer's YOLO (idle VRAM reclaim). The worker
also drops its reference to this Proposers and rebuilds a fresh one via
_proposers_for on the next job, so this is belt-and-braces."""
for p in (self._person, self._anatomy, self._panel):
if p is not None:
p.unload()
+15
View File
@@ -75,3 +75,18 @@ class CropEmbedder:
pooled = out.pooler_output if hasattr(out, "pooler_output") else out
arr = pooled.float().cpu().numpy().astype(np.float32)
return [row.reshape(-1).tolist() for row in arr]
def unload(self) -> bool:
"""Drop the loaded model so its VRAM can be reclaimed — the idle monitor
calls this after a spell with no work so an idle agent doesn't squat on
the card; the next embed() reloads it lazily (a few seconds). Held under
BOTH the load and inference locks so it can never race a concurrent load
or an in-flight forward pass. Returns True if a model was actually
released (the caller then runs one empty_cache() to hand the freed blocks
back to the driver)."""
with self._load_lock, self._infer_lock:
if self._model is None:
return False
self._model = None
self._processor = None
return True
+75
View File
@@ -57,6 +57,15 @@ MAX_BACKOFF_SECONDS = 60.0
# up on their own.
IDLE_POLL_MAX_SECONDS = 900.0
# Idle VRAM reclaim (operator 2026-07-17): the SigLIP embedder + YOLO proposers
# load lazily and then stay warm for fast job bursts — but a 24/7 agent with an
# empty queue would otherwise squat on that VRAM (~5GB on the operator's card)
# indefinitely while doing nothing. So a monitor unloads them after
# cfg.idle_unload_seconds with the GPU genuinely idle (nothing in flight, buffer
# drained); they reload lazily on the next job. This is just how often the
# monitor wakes to check — it bounds how soon past the threshold the unload fires.
IDLE_UNLOAD_CHECK_INTERVAL = 30.0
# A job whose fetch dies transiently this many times IN ONE SESSION stops being
# handed back and is failed instead. Transient handbacks (release) burn no
# attempts on the server, so a poisoned transfer — an original that stalls the
@@ -268,6 +277,11 @@ class Worker:
self._proposers_sig = None # detector-config signature the current
# proposers were built for (#134)
self._proposers_lock = threading.Lock()
# Monotonic time of the last GPU activity (a consumer finishing a job).
# The idle monitor unloads the warm models once this goes stale by
# cfg.idle_unload_seconds — see _idle_unload_loop.
self._last_gpu_activity = time.monotonic()
threading.Thread(target=self._idle_unload_loop, daemon=True).start()
# --- held-lease bookkeeping --------------------------------------------
def _hold(self, job_ids) -> None:
@@ -608,6 +622,9 @@ class Worker:
"net_mb_s": round(self._net_mb_s, 1), # observed aggregate rate
"bw_capped": self._bw_capped, # autoscaler holding at the cap (UI hint)
"idle": self._idle, # queue empty → poll backed off (UI hint)
# Whether the GPU models are currently resident (False after an idle
# unload freed their VRAM) — a plain bool read, UI hint only.
"models_loaded": self._embedder is not None or self._proposers is not None,
}
def _bump(self, *, processed=0, downloaded=0, errors=0, active=0, transient=0):
@@ -788,6 +805,9 @@ class Worker:
self._bump(processed=1)
finally:
self._bump(active=-1)
# Mark the GPU busy-until-now so the idle monitor starts its
# unload countdown from when work actually stopped, not before.
self._last_gpu_activity = time.monotonic()
def _ensure_embedder(self, model_name: str):
if self._embedder is not None:
@@ -845,6 +865,61 @@ class Worker:
self._proposers_sig = sig
return self._proposers
def _unload_models(self) -> bool:
"""Release the GPU-resident models (SigLIP embedder + YOLO proposers) so an
idle agent hands their VRAM back instead of squatting on the card. They
reload lazily on the next job (_ensure_embedder / _proposers_for) — a
few seconds' cost paid only when work actually resumes. Dropping the
shared instances under their build locks means a concurrent job either
sees the old instance (before) or rebuilds a fresh one (after); the idle
monitor only calls this with nothing in flight, so no inference is using
them. Returns True if anything was released."""
released = False
with self._embedder_lock:
if self._embedder is not None:
self._embedder.unload()
self._embedder = None
released = True
with self._proposers_lock:
if self._proposers is not None:
self._proposers.unload()
self._proposers = None
self._proposers_sig = None
released = True
if released:
try:
import torch
if torch.cuda.is_available():
# torch's caching allocator holds freed blocks; hand them back
# to the driver so nvidia-smi actually reflects the drop.
torch.cuda.empty_cache()
except Exception: # noqa: BLE001 — torch absent / CPU-only → nothing to free
pass
return released
def _idle_unload_loop(self) -> None:
"""Unload the warm GPU models after a stretch of inactivity so a 24/7
agent with an empty queue doesn't hold ~5GB of VRAM doing nothing. Fires
only when nothing is in flight (active == 0 AND the buffer is drained) and
no job has completed for cfg.idle_unload_seconds — a window long enough
that a brief lull between bursts doesn't thrash reload/unload. Covers BOTH
sleep mode (queue empty, pipeline still running) and a full Stop; the
models reload lazily on the next job. idle_unload_seconds <= 0 disables it."""
idle_after = self.cfg.idle_unload_seconds
if idle_after <= 0:
return
while True:
time.sleep(IDLE_UNLOAD_CHECK_INTERVAL)
if self._embedder is None and self._proposers is None:
continue # nothing loaded → nothing to free
if self._active != 0 or not self._buffer.empty():
continue # work in flight → keep them warm
if time.monotonic() - self._last_gpu_activity < idle_after:
continue # not idle long enough yet
if self._unload_models():
log.info("idle %.0fs — unloaded GPU models, freed VRAM "
"(reload on next job)", idle_after)
def _consume(self, job: dict, frames: list, stop_evt: threading.Event) -> bool:
"""Detect + embed the decoded frames and submit the result. Returns True
when the job was completed (→ count it processed), False otherwise: a
@@ -0,0 +1,35 @@
"""title-based WIP auto-tagging (task #1458) — ImportSettings toggle
ImportSettings gains wip_title_tagging_enabled (ON by default): when a freshly
imported post's title explicitly declares work-in-progress ("WIP" / "work in
progress"), the importer applies the `wip` system tag to its images. No new
table — the tag itself is the seeded `wip` system tag (migration 0075) and the
application reuses image_tag with source='wip_title'.
Revision ID: 0085
Revises: 0084
Create Date: 2026-07-12
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0085"
down_revision: Union[str, None] = "0084"
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_title_tagging_enabled", sa.Boolean(), nullable=False,
server_default=sa.text("true"),
),
)
def downgrade() -> None:
op.drop_column("import_settings", "wip_title_tagging_enabled")
@@ -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
# rabbit-hole; the gallery's own "similar" button omits it (keeps wip, #1274).
exclude_wip = request.args.get("exclude_wip") in ("1", "true", "True")
# Explore reach (#1476): 0 = nearest (gallery default), →1 reaches into farther
# distance bands so the walk can escape a dense cluster. exclude_ids = the
# breadcrumb, so already-walked images aren't re-served as neighbours.
try:
reach = max(0.0, min(1.0, float(request.args.get("reach", "0"))))
except ValueError:
reach = 0.0
exclude_ids = [
int(x) for x in request.args.get("exclude_ids", "").split(",")
if x.strip().isdigit()
] or None
# post_id is the exclusive post-detail view — not a similarity scope.
# include_hidden is a gallery-browse flag; similar() has its OWN presentation
# exclusion (a similarity-quality concern, #1274), so drop it here (#141).
@@ -158,7 +169,8 @@ async def similar():
svc = GalleryService(session)
try:
images = await svc.similar(
image_id=similar_to, limit=limit, exclude_wip=exclude_wip, **scope)
image_id=similar_to, limit=limit, exclude_wip=exclude_wip,
reach=reach, exclude_ids=exclude_ids, **scope)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if images is None:
@@ -236,8 +248,10 @@ async def jump():
# content", surfaced in the gallery's Show-hidden review strip. -----------
@gallery_bp.route("/hidden-review", methods=["GET"])
async def hidden_review():
"""Unresolved presentation auto-hide flags, most-concerning first (highest
content score) — for the gallery's Hidden-view review strip."""
"""Unresolved system-tag auto-apply review flags (chrome + process, #1464),
most-concerning first (highest content score) — for the review strip. `mode`
tells the client whether the flagged tag hid the image ('chrome') or left it
visible ('process'), which decides the resolve labels (un-hide vs remove-tag)."""
ptag = aliased(Tag)
ctag = aliased(Tag)
async with get_session() as session:
@@ -247,6 +261,7 @@ async def hidden_review():
PresentationReview.tag_id,
PresentationReview.conflict_tag_id,
PresentationReview.conflict_score,
PresentationReview.mode,
ImageRecord.path, ImageRecord.thumbnail_path,
ImageRecord.sha256, ImageRecord.mime,
ptag.name.label("tag_name"),
@@ -266,6 +281,7 @@ async def hidden_review():
"conflict_tag_id": r.conflict_tag_id,
"conflict_name": r.conflict_name,
"conflict_score": r.conflict_score,
"mode": r.mode,
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
"image_url": image_url(r.path),
}
+1 -3
View File
@@ -256,9 +256,7 @@ async def lease():
if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401
jobs = await GpuJobService(session).lease(agent_id, batch_size=batch)
ml = (
await session.execute(select(MLSettings).where(MLSettings.id == 1))
).scalar_one()
ml = await MLSettings.load(session)
# image rows for url/mime in one shot
ids = [j.image_record_id for j in jobs]
imgs = {
+24 -38
View File
@@ -4,6 +4,7 @@ from quart import Blueprint, jsonify, request
from ..extensions import get_session
from ..models import MLSettings
from ..services.ml.heads import AUTO_APPLY_THRESHOLD_MAX, AUTO_APPLY_THRESHOLD_MIN
ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
@@ -42,6 +43,9 @@ _EDITABLE = (
"presentation_auto_apply_enabled",
"presentation_auto_apply_threshold",
"presentation_conflict_threshold",
"process_auto_apply_enabled",
"process_auto_apply_threshold",
"process_conflict_threshold",
"embedder_model_name",
"embedder_model_version",
*_DETECTOR_FIELDS,
@@ -80,45 +84,21 @@ async def embedder_models():
@ml_admin_bp.route("/settings", methods=["GET"])
async def get_settings():
from sqlalchemy import select
async with get_session() as session:
s = (
await session.execute(select(MLSettings).where(MLSettings.id == 1))
).scalar_one()
return jsonify(
{
"cpu_embed_enabled": s.cpu_embed_enabled,
"video_frame_interval_seconds": s.video_frame_interval_seconds,
"video_max_frames": s.video_max_frames,
"embedder_model_version": s.embedder_model_version,
"head_min_positives": s.head_min_positives,
"head_auto_apply_precision": s.head_auto_apply_precision,
"head_auto_apply_enabled": s.head_auto_apply_enabled,
"head_auto_apply_min_positives": s.head_auto_apply_min_positives,
"ccip_match_threshold": s.ccip_match_threshold,
"ccip_auto_apply_enabled": s.ccip_auto_apply_enabled,
"ccip_auto_apply_threshold": s.ccip_auto_apply_threshold,
"presentation_auto_apply_enabled": s.presentation_auto_apply_enabled,
"presentation_auto_apply_threshold": s.presentation_auto_apply_threshold,
"presentation_conflict_threshold": s.presentation_conflict_threshold,
"embedder_model_name": s.embedder_model_name,
**{f: getattr(s, f) for f in _DETECTOR_FIELDS},
}
)
s = await MLSettings.load(session)
# Table-driven off _EDITABLE (which PATCH also writes) so a new settings field
# can never be silently absent from GET — the split that historically dropped
# fields. _EDITABLE already includes *_DETECTOR_FIELDS.
return jsonify({f: getattr(s, f) for f in _EDITABLE})
@ml_admin_bp.route("/settings", methods=["PATCH"])
async def patch_settings():
from sqlalchemy import select
body = await request.get_json()
if not isinstance(body, dict):
return jsonify({"error": "body must be an object"}), 400
async with get_session() as session:
s = (
await session.execute(select(MLSettings).where(MLSettings.id == 1))
).scalar_one()
s = await MLSettings.load(session)
# Merge the patch over current values, then validate the result as a
# whole — the store-floor invariant couples three fields, so they
@@ -148,20 +128,26 @@ def _validate(p: dict) -> str | None:
# Head training (#114).
if int(p["head_min_positives"]) < 1:
return "head_min_positives must be >= 1"
if not (0.5 <= float(p["head_auto_apply_precision"]) <= 0.999):
return "head_auto_apply_precision must be between 0.5 and 0.999"
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["head_auto_apply_precision"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"head_auto_apply_precision must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
if int(p["head_auto_apply_min_positives"]) < 1:
return "head_auto_apply_min_positives must be >= 1"
if not (0.5 <= float(p["ccip_match_threshold"]) <= 0.999):
return "ccip_match_threshold must be between 0.5 and 0.999"
if not (0.5 <= float(p["ccip_auto_apply_threshold"]) <= 0.999):
return "ccip_auto_apply_threshold must be between 0.5 and 0.999"
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_match_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"ccip_match_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"ccip_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
# Presentation chrome auto-hide (#141). Auto-apply runs high (hiding is
# consequential); the conflict cut is a plain probability [0,1].
if not (0.5 <= float(p["presentation_auto_apply_threshold"]) <= 0.999):
return "presentation_auto_apply_threshold must be between 0.5 and 0.999"
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["presentation_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"presentation_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
if not (0.0 <= float(p["presentation_conflict_threshold"]) <= 1.0):
return "presentation_conflict_threshold must be between 0 and 1"
# Process auto-apply (#1464). wip/editor stay VISIBLE so a false apply is
# low-harm (excludes-from-training + a review flag), but keep the same bar.
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["process_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"process_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
if not (0.0 <= float(p["process_conflict_threshold"]) <= 1.0):
return "process_conflict_threshold must be between 0 and 1"
# Embedder model swap (#1190): both must be non-empty. Changing them means a
# different embedding space — the operator must re-embed + retrain after.
for key in ("embedder_model_name", "embedder_model_version"):
+29 -26
View File
@@ -48,6 +48,8 @@ _EDITABLE_FIELDS = (
"interpreter_base_url",
"translation_target_lang",
"translation_min_confidence",
"wip_title_tagging_enabled",
"wip_soft_title_tagging_enabled",
)
# Per-host external-download toggles — all plain booleans, validated uniformly.
@@ -64,32 +66,9 @@ _EXTDL_TOGGLE_FIELDS = (
async def get_import_settings():
async with get_session() as session:
row = await ImportSettings.load(session)
return jsonify({
"min_width": row.min_width,
"min_height": row.min_height,
"skip_transparent": row.skip_transparent,
"transparency_threshold": row.transparency_threshold,
"skip_single_color": row.skip_single_color,
"single_color_threshold": row.single_color_threshold,
"single_color_tolerance": row.single_color_tolerance,
"phash_threshold": row.phash_threshold,
"download_rate_limit_seconds": row.download_rate_limit_seconds,
"download_validate_files": row.download_validate_files,
"download_schedule_default_seconds": row.download_schedule_default_seconds,
"download_event_retention_days": row.download_event_retention_days,
"download_failure_warning_threshold": row.download_failure_warning_threshold,
"series_suggest_enabled": row.series_suggest_enabled,
"series_suggest_threshold": row.series_suggest_threshold,
"extdl_mega_enabled": row.extdl_mega_enabled,
"extdl_gdrive_enabled": row.extdl_gdrive_enabled,
"extdl_mediafire_enabled": row.extdl_mediafire_enabled,
"extdl_dropbox_enabled": row.extdl_dropbox_enabled,
"extdl_pixeldrain_enabled": row.extdl_pixeldrain_enabled,
"translation_enabled": row.translation_enabled,
"interpreter_base_url": row.interpreter_base_url,
"translation_target_lang": row.translation_target_lang,
"translation_min_confidence": row.translation_min_confidence,
})
# Table-driven off _EDITABLE_FIELDS (which PATCH also writes) so a new field
# can't be silently absent from GET.
return jsonify({f: getattr(row, f) for f in _EDITABLE_FIELDS})
@settings_bp.route("/settings/import", methods=["PATCH"])
@@ -171,6 +150,18 @@ async def update_import_settings():
return jsonify(
{"error": "series_suggest_threshold must be a number in [0, 1]"}
), 400
if "wip_title_tagging_enabled" in body and not isinstance(
body["wip_title_tagging_enabled"], bool
):
return jsonify(
{"error": "wip_title_tagging_enabled must be a boolean"}
), 400
if "wip_soft_title_tagging_enabled" in body and not isinstance(
body["wip_soft_title_tagging_enabled"], bool
):
return jsonify(
{"error": "wip_soft_title_tagging_enabled must be a boolean"}
), 400
async with get_session() as session:
row = await ImportSettings.load(session)
@@ -182,6 +173,18 @@ async def update_import_settings():
return await get_import_settings()
@settings_bp.route("/settings/wip-title/scan", methods=["POST"])
async def wip_title_scan():
"""Enqueue the back-catalogue WIP-title scan (task #1458 Settings button):
apply the `wip` system tag to EXISTING posts whose title declares
work-in-progress. New imports are tagged live by the importer; this catches
the existing library. Returns the Celery task id (202)."""
from ..tasks.maintenance import backfill_wip_title_tags
r = backfill_wip_title_tags.delay()
return jsonify({"celery_task_id": r.id}), 202
@settings_bp.route("/system/stats", methods=["GET"])
async def system_stats():
async with get_session() as session:
+11 -1
View File
@@ -171,9 +171,19 @@ def make_celery() -> Celery:
},
"presentation-auto-apply-daily": {
"task": "backend.app.tasks.ml.scheduled_presentation_auto_apply",
"schedule": 86400.0, # auto-hide banner/editor chrome (#141);
"schedule": 86400.0, # auto-hide banner chrome (#141);
# no-op unless presentation_auto_apply_enabled
},
"process-auto-apply-daily": {
"task": "backend.app.tasks.ml.scheduled_process_auto_apply",
"schedule": 86400.0, # auto-tag wip/editor process art (#1464);
# no-op unless process_auto_apply_enabled (opt-in)
},
"soft-wip-conflict-audit-daily": {
"task": "backend.app.tasks.ml.scheduled_soft_wip_conflict_audit",
"schedule": 86400.0, # flag ring-loud soft-WIP (sketch/doodle) tags
# for review (#1474); no-op with no content heads
},
"prune-presentation-reviews-daily": {
"task": "backend.app.tasks.ml.prune_presentation_reviews",
"schedule": 86400.0, # retention: drop resolved review flags >30d
+18
View File
@@ -116,6 +116,24 @@ class ImportSettings(Base):
Float, nullable=False, default=0.9, server_default="0.9",
)
# Title-based WIP auto-tagging (task #1458). When a freshly-imported post's
# TITLE explicitly declares work-in-progress ("WIP" / "work in progress"),
# the importer applies the `wip` system tag to its images — the artist's own
# label, used to keep unfinished pieces out of the Explore/gallery browse. ON
# by default (rule 26 — the feature works out of the box). Gates only the
# LIVE import hook; the existing catalogue is caught by the operator-triggered
# "Scan existing posts" backfill (which runs regardless of this flag).
wip_title_tagging_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
# Soft WIP title tier (#1474): also tag sketch/doodle/scribble titles, but with
# a PROVISIONAL source (`wip_title_soft`) that never trains the head, since these
# are lower-precision (a finished "sketch" isn't WIP). OFF by default — a lower-
# precision tier is opt-in (the ring-loud audit surfaces false positives).
wip_soft_title_tagging_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false",
)
@classmethod
async def load(cls, session) -> ImportSettings:
"""The singleton settings row (id=1), via an async session."""
+40 -6
View File
@@ -10,6 +10,7 @@ from sqlalchemy import (
Integer,
String,
func,
select,
)
from sqlalchemy.orm import Mapped, mapped_column
@@ -85,12 +86,14 @@ class MLSettings(Base):
Float, nullable=False, default=0.95
)
# -- Presentation chrome auto-hide (#141) -------------------------------
# banner / editor screenshot auto-apply on the sweep with their OWN flat
# threshold (decoupled from content-head graduation). Hiding is consequential
# so it runs HIGH. `wip` is never auto-applied. When an image would be
# auto-hidden but ALSO scores >= presentation_conflict_threshold on a content
# head, it's still hidden but flagged for review (PresentationReview) instead
# of buried silently. ON by default (opt-out); every auto-tag is reversible.
# `banner` (chrome — clusters on UI, not content) auto-applies on the sweep
# with its OWN flat threshold (decoupled from content-head graduation) and is
# HIDDEN from the gallery. Hiding is consequential so it runs HIGH. When an
# image would be auto-hidden but ALSO scores >= presentation_conflict_threshold
# on a content head, it's still hidden but flagged for review
# (PresentationReview, mode='chrome') instead of buried silently. ON by default
# (opt-out); every auto-tag is reversible. NOTE (#1464): `wip` + `editor
# screenshot` are no longer chrome — they went to the PROCESS path below.
presentation_auto_apply_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
@@ -100,6 +103,26 @@ class MLSettings(Base):
presentation_conflict_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50
)
# -- Process auto-apply (#1464) ----------------------------------------
# `wip` / `editor screenshot` are PROCESS art — unfinished pieces + program
# screenshots that must stay OUT of head/CCIP training but, unlike chrome,
# remain VISIBLE in the gallery (operator 2026-07-12). They auto-apply on the
# sweep with their OWN flat threshold and a PROVISIONAL source (`process_auto`,
# in training_data._AUTO_SOURCES) so the head NEVER trains on its own output —
# it learns only from title (`wip_title`) + manual labels, which breaks the
# runaway loop. When a process tag would be applied but the image ALSO scores
# >= process_conflict_threshold on a content head, it's flagged for review
# (PresentationReview, mode='process') rather than silently marked. OFF by
# default — a new whole-library auto-tagger is opt-in; every auto-tag reversible.
process_auto_apply_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
process_auto_apply_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.90
)
process_conflict_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50
)
# Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069);
# existing libraries keep their stored value until the operator re-embeds.
embedder_model_version: Mapped[str] = mapped_column(
@@ -190,3 +213,14 @@ class MLSettings(Base):
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
@classmethod
async def load(cls, session) -> MLSettings:
"""The singleton settings row (id=1), via an async session. Mirrors
ImportSettings.load — the shared singleton-loader pattern."""
return (await session.execute(select(cls).where(cls.id == 1))).scalar_one()
@classmethod
def load_sync(cls, session) -> MLSettings:
"""The singleton settings row (id=1), via a sync session."""
return session.execute(select(cls).where(cls.id == 1)).scalar_one()
+15 -7
View File
@@ -1,15 +1,17 @@
"""PresentationReview — an auto-hidden presentation tag that ALSO looked like
real content, flagged for operator review (milestone 141).
"""PresentationReview — a system-tag the auto-apply sweep applied that ALSO looked
like real content, flagged for operator review (milestone 141 + #1464).
When the auto-apply sweep hides an image as chrome (banner / editor screenshot)
but the image ALSO scores highly on a content head, it still hides it but records
this row so the Hidden view can surface it ("⚠ also looks like <conflict tag>")
for a keep-hidden / un-hide decision. Resolved rows are pruned by retention.
When a sweep applies a system tag but the image ALSO scores highly on a content
head, it still applies the tag but records this row so a review strip can surface
it ("⚠ also looks like <conflict tag>"). Two modes (#1464): 'chrome' (banner —
image is HIDDEN, review is keep-hidden / un-hide) and 'process' (wip / editor
screenshot — image stays VISIBLE, review is confirm / remove-tag). Resolved rows
are pruned by retention.
"""
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, func
from sqlalchemy import DateTime, Float, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -31,6 +33,12 @@ class PresentationReview(Base):
ForeignKey("tag.id", ondelete="SET NULL"), nullable=True
)
conflict_score: Mapped[float] = mapped_column(Float, nullable=False)
# Which sweep flagged this (#1464): 'chrome' (banner, hidden) or 'process'
# (wip / editor screenshot, shown). Drives which review strip surfaces it and
# what "resolve" means (un-hide vs remove-tag). Existing rows backfill 'chrome'.
mode: Mapped[str] = mapped_column(
String(16), nullable=False, default="chrome", server_default="chrome"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+12 -7
View File
@@ -43,14 +43,19 @@ class TagKind(StrEnum):
# to keep historic tag rows queryable.
# The seeded system tags (migration 0075). PRESENTATION tags additionally
# hide from whole-image similarity results — they cluster on UI chrome, not
# content. `wip` is real art: only the training pipelines exclude it.
# The seeded system tags (migration 0075). Two behavior groups (#1464):
# CHROME (banner): clusters on UI chrome, not content → HIDDEN from the default
# gallery + from similarity; auto-applied via the sweep's chrome mode.
# PROCESS (wip, editor screenshot): real-but-unfinished art / program screenshots
# → SHOWN in the gallery (operator 2026-07-12), but excluded from the Explore
# rabbit-hole; auto-applied via the sweep's process mode (provisional source,
# ring-loud review guard).
# ALL three are excluded from OTHER concepts' head/CCIP training (training-hygiene,
# keyed on is_system); a system tag's OWN head trains on them — that's what makes
# auto-flagging work.
SYSTEM_TAG_NAMES = ("wip", "banner", "editor screenshot")
PRESENTATION_SYSTEM_TAGS = ("banner", "editor screenshot")
# `wip` marks real-but-unfinished art. It's kept in the gallery's own "similar"
# results (#1274), but the Explore rabbit-hole opts to hide it (exclude_wip) so a
# browse doesn't keep surfacing work-in-progress (operator, 2026-07-08).
CHROME_SYSTEM_TAGS = ("banner",)
PROCESS_SYSTEM_TAGS = ("wip", "editor screenshot")
WIP_SYSTEM_TAG = "wip"
image_tag = Table(
+7 -2
View File
@@ -35,9 +35,14 @@ class InvalidUrlError(Exception):
# reviewers catch drift.
_PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("patreon", re.compile(
# Three creator URL shapes — bare (patreon.com/Atole), `c/`, and `cw/`
# (the "creator workspace" URL served once subscribed, see
# patreon_resolver._VANITY_RE). A trailing sub-path is allowed so a
# creator's inner page still derives the slug. Nav pages stay excluded.
r"^https?://(?:www\.)?patreon\.com/"
r"(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c/)"
r"(?P<slug>[^/?#]+)/?$",
r"(?:cw/|c/)?"
r"(?!(?:home|search|messages|notifications|library|settings|posts)(?:[/?#]|$))"
r"(?P<slug>[^/?#]+)",
re.IGNORECASE,
)),
("subscribestar", re.compile(
+51 -15
View File
@@ -31,7 +31,7 @@ from ..models import (
Tag,
TagPositiveConfirmation,
)
from ..models.tag import PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG, image_tag
from ..models.tag import CHROME_SYSTEM_TAGS, PROCESS_SYSTEM_TAGS, image_tag
from .pagination import decode_cursor, encode_cursor
from .tag_query import (
fandom_join_alias,
@@ -396,6 +396,25 @@ def _diversify_similar(src, rows, limit, *, dup_threshold=8, lam=0.40):
return [kept[i] for i in order]
def _reach_sample(rows, limit, reach):
"""From a distance-sorted candidate pool (nearest first), pick a spread of ranks
that MIXES near (tag the current cluster) and mid-far (escape it) BEFORE dedup +
MMR — the Explore "reach" dial (#1476).
reach in (0, 1]: the sampled span grows outward from the anchor (0.25→1.0 of the
pool), evenly strided from rank 0 so the nearest are still represented. In a
dense signature the nearest ranks are near-identical, so reaching farther is the
only way to hand MMR genuinely different content — MMR alone can't escape a pool
that's already all-near. reach<=0 or a small pool passes through unchanged."""
n = len(rows)
want = max(limit * 8, 100)
if reach <= 0 or n <= want:
return rows
span = int(min(1.0, 0.25 + 0.75 * reach) * n)
idx = sorted({min(int(i * span / want), n - 1) for i in range(want)})
return [rows[i] for i in idx]
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
"""Map image_id -> {"name","slug"} via the canonical
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
@@ -419,16 +438,17 @@ class GalleryService:
async def _hidden_tag_ids(
self, include_hidden, tag_ids, tag_or_groups,
) -> list[int] | None:
"""Presentation-chrome tag ids to implicitly exclude from a gallery query,
or None. None when the caller asked to include hidden, when the operator
is explicitly filtering FOR a presentation tag (they clearly want to see
it), or when no presentation tags exist. (milestone 141)"""
"""Chrome (banner) tag ids to implicitly exclude from a gallery query, or
None. None when the caller asked to include hidden, when the operator is
explicitly filtering FOR a chrome tag (they clearly want to see it), or when
no chrome tags exist. (milestone 141; #1464: editor screenshot is now PROCESS
— shown — so only `banner` hides here.)"""
if include_hidden:
return None
rows = await self.session.execute(
select(Tag.id).where(
Tag.is_system.is_(True),
Tag.name.in_(PRESENTATION_SYSTEM_TAGS),
Tag.name.in_(CHROME_SYSTEM_TAGS),
)
)
pres = [r[0] for r in rows]
@@ -716,6 +736,7 @@ class GalleryService:
untagged: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_to: datetime | None = None,
exclude_wip: bool = False,
reach: float = 0.0, exclude_ids: list[int] | None = None,
) -> list[GalleryImage] | None:
"""Visual "more like this": images near `image_id`'s SigLIP embedding
(pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result
@@ -744,20 +765,27 @@ class GalleryService:
# wide pool there's nothing but the near-dupes to choose from. Widened
# (5×→8×, cap 200→400) so the stronger MMR has genuinely distinct
# neighbourhoods to reach into for more variance (operator, 2026-07-01).
pool_n = min(400, max(limit * 8, 100))
# Explore's reach>0 (#1476) widens it a LOT more: in a dense signature the
# nearest few hundred are all near-identical, so far-enough candidates only
# exist deeper in the ranked pool. _reach_sample then strides across them.
if reach > 0:
pool_n = min(1000, max(limit * 25, 100))
else:
pool_n = min(400, max(limit * 8, 100))
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
eff = _effective_date_col()
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
stmt = _outer_join_primary_post(stmt)
# Presentation images (banner / editor-screenshot system tags, #128)
# cluster on UI chrome rather than content, so near any one of them
# they'd fill the grid. Excluded from CANDIDATES only — the anchor
# itself may be a banner. `wip` stays surfaced here by default (real art;
# only the training pipelines exclude it), but the Explore rabbit-hole
# passes exclude_wip to also drop work-in-progress (operator, 2026-07-08).
excluded_system_tags = PRESENTATION_SYSTEM_TAGS
# Chrome (banner, #128) clusters on UI rather than content, so near any one
# of them they'd fill the grid → excluded from CANDIDATES always (the anchor
# itself may be a banner). PROCESS art (wip / editor screenshot) stays
# surfaced here by default (real content; only the training pipelines exclude
# it), but the Explore rabbit-hole passes exclude_wip to also drop the whole
# process group so a browse doesn't keep surfacing work-in-progress
# (operator, 2026-07-08; #1464 — editor now rides with wip here).
excluded_system_tags = CHROME_SYSTEM_TAGS
if exclude_wip:
excluded_system_tags = (*PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG)
excluded_system_tags = (*CHROME_SYSTEM_TAGS, *PROCESS_SYSTEM_TAGS)
presentation = (
select(image_tag.c.image_record_id)
.join(Tag, Tag.id == image_tag.c.tag_id)
@@ -771,6 +799,10 @@ class GalleryService:
ImageRecord.id != image_id,
ImageRecord.id.not_in(presentation),
)
# Anti-revisit (#1476): the Explore walk passes its breadcrumb so already-
# walked images aren't re-served as neighbours — → can't loop you back in.
if exclude_ids:
stmt = stmt.where(ImageRecord.id.not_in(exclude_ids))
stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=None,
artist_id=artist_id, media_type=media_type,
@@ -780,6 +812,10 @@ class GalleryService:
)
stmt = stmt.order_by(distance.asc()).limit(pool_n)
rows = (await self.session.execute(stmt)).all()
# Explore reach: stride across an outward-growing distance span so the pool
# handed to MMR spans near→mid-far, not just the tight cluster (#1476).
if reach > 0:
rows = _reach_sample(rows, limit, reach)
rows = _diversify_similar(src, rows, limit)
artists = await _artists_for(self.session, [r[0].id for r in rows])
return _gallery_images(rows, artists)
+65
View File
@@ -47,9 +47,21 @@ from .attachment_store import AttachmentStore
from .audits import single_color
from .link_extract import extract_external_links
from .thumbnailer import Thumbnailer
from .wip_title import (
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__)
# Sentinel for the lazily-resolved wip tag id (distinguishes "not resolved yet"
# from a genuine None = tag absent, so absence is cached and not re-queried).
_UNSET = object()
class SkipReason(StrEnum):
too_small = "too_small"
@@ -183,6 +195,10 @@ class Importer:
# invalidated mid-Importer (Importer instances are per-task /
# per-archive-import so cross-instance staleness is harmless).
self._phash_candidates: list[tuple] | None = None
# Lazily-resolved `wip` system tag id for title-based WIP auto-tagging
# (task #1458). Sentinel _UNSET so a genuine None (tag absent) is cached
# and not re-queried per media. Importer is per-task, so this can't stale.
self._wip_tag_id: int | None = _UNSET
def _phash_candidates_cache(self) -> list[tuple]:
"""Cached `(phash, width, height, id)` rows from image_record.
@@ -933,6 +949,10 @@ class Importer:
# Thumbnail is queued separately by the calling task; the importer
# does not generate thumbnails inline so the import queue stays moving.
# Title-based WIP auto-tag (task #1458): fresh import only, after the
# sidecar has linked the post so record.primary_post_id / its title exist.
self._maybe_apply_wip_title(record)
self.session.commit()
return ImportResult(status="imported", image_id=record.id)
@@ -976,6 +996,47 @@ class Importer:
self.session.commit()
return ImportResult(status="refreshed", image_id=existing.id)
def _maybe_apply_wip_title(self, record: ImageRecord) -> None:
"""Auto-apply the `wip` system tag to a FRESHLY-imported image when its
primary post's TITLE explicitly declares work-in-progress (task #1458 —
the artist's own "WIP" / "work in progress" label).
Called ONLY from the two new-record paths (never deep-scan / supersede),
so a manually-removed WIP tag is never re-applied by a routine re-scan —
removal sticks. The existing catalogue is covered separately by the
operator-triggered backfill sweep. Gated by the settings toggle, and
best-effort: any failure is logged, never allowed to fail the import."""
hard_on = self.settings.wip_title_tagging_enabled
soft_on = self.settings.wip_soft_title_tagging_enabled
if not (hard_on or soft_on):
return
if record.primary_post_id is None:
return
try:
title = self.session.execute(
select(Post.post_title).where(Post.id == record.primary_post_id)
).scalar_one_or_none()
# HARD tier ("WIP"/"work in progress") wins — higher precision, and it
# trains the head; SOFT (sketch/doodle, #1474) is the provisional fallback
# that never trains (source wip_title_soft).
if hard_on and matches_wip_title(title):
source = WIP_TITLE_SOURCE
elif soft_on and matches_soft_wip_title(title):
source = WIP_TITLE_SOFT_SOURCE
else:
return
if self._wip_tag_id is _UNSET:
self._wip_tag_id = resolve_wip_tag_id(self.session)
if self._wip_tag_id is None:
return
apply_wip_image_tags(
self.session, [record.id], self._wip_tag_id, source=source
)
except Exception as exc: # noqa: BLE001 — a tag must never fail an import
log.warning(
"wip-title auto-tag failed for image %s: %s", record.id, exc
)
def _apply_post_fields(self, post: Post, sd) -> None:
"""Write a parsed sidecar's post-level fields onto a Post — the SINGLE
predicate shared by BOTH ingest paths: the per-media path (_apply_sidecar)
@@ -1253,6 +1314,10 @@ class Importer:
# per-post Source row.
self._apply_sidecar(record, path, artist, explicit_source=source)
# Title-based WIP auto-tag (task #1458): fresh import only, see the
# matching call in _import_media.
self._maybe_apply_wip_title(record)
self.session.commit()
return ImportResult(status="imported", image_id=record.id)
@@ -150,9 +150,7 @@ def refresh_character_prototypes(
"""Incrementally refresh the prototype store. `full=True` rebuilds every
character regardless of the gate/fingerprints (nightly reconcile). Returns
{skipped, rebuilt, removed}; commits."""
settings = session.execute(
select(MLSettings).where(MLSettings.id == 1)
).scalar_one()
settings = MLSettings.load_sync(session)
sig = _global_signature(session)
if not full and settings.ccip_ref_signature == sig:
return {"skipped": True, "rebuilt": 0, "removed": 0}
@@ -204,9 +202,7 @@ def retract_auto_applied_ccip(session: Session) -> int:
n_retracted."""
import numpy as np
settings = session.execute(
select(MLSettings).where(MLSettings.id == 1)
).scalar_one()
settings = MLSettings.load_sync(session)
if not settings.ccip_auto_apply_enabled:
return 0
thr = float(settings.ccip_auto_apply_threshold)
+173 -69
View File
@@ -23,6 +23,7 @@ from datetime import UTC, datetime
from typing import Any
from sqlalchemy import delete, exists, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
@@ -39,9 +40,10 @@ from ...models import (
TagPositiveConfirmation,
TagSuggestionRejection,
)
from ...models.tag import PRESENTATION_SYSTEM_TAGS, image_tag
from ...models.tag import CHROME_SYSTEM_TAGS, PROCESS_SYSTEM_TAGS, image_tag
from .training_data import (
_AUTO_SOURCES,
_applied_or_rejected,
_auto_apply_point,
_hygiene_excluded_ids,
_ids_with_tag,
@@ -61,6 +63,14 @@ MIN_POSITIVES_FLOOR = 8 # hard floor; settings.head_min_positives can raise
_UNLABELED_POOL = 4000
_EXAMPLES_MIN = 8 # need at least this many embedded +/- to fit a head
# Auto-apply / match confidence operating range. Every graduated auto-apply or
# CCIP-match threshold the operator can set lives in this band, and the head
# precision target is clamped to it: below 0.5 "auto-apply" is meaningless, and
# 1.0 is unachievable so 0.999 is the ceiling. One source shared by the service
# clamp (_normalize_params) and the API validator (ml_admin._validate).
AUTO_APPLY_THRESHOLD_MIN = 0.5
AUTO_APPLY_THRESHOLD_MAX = 0.999
# Only these tag kinds get heads (the surfaced suggestion categories).
_HEAD_KINDS = (TagKind.general, TagKind.character)
# tag.kind -> the suggestion category the rail groups under.
@@ -78,6 +88,38 @@ _CATEGORY = {TagKind.general: "general", TagKind.character: "character"}
_SYSTEM_TAG_SUGGEST_FLOOR = 0.65
def _sigmoid(z, np):
"""Logistic sigmoid 1/(1+e^-z): the head score→probability transform. One home
for what was inlined at every scoring site (suggest, both sweeps, retract)."""
return 1.0 / (1.0 + np.exp(-z))
def _conflict_scores(Xn, Wc, bc, np):
"""The presentation conflict signal (#141): per row, the MAX content-head
probability and WHICH head produced it. Shared by the system-tag sweep's guard-2
and the soft-wip audit — both ask "does this ALSO look like real content?"."""
cprobs = _sigmoid(Xn @ Wc.T + bc, np)
return cprobs.max(axis=1), cprobs.argmax(axis=1)
def _insert_presentation_review(
session, *, image_record_id, tag_id, conflict_tag_id, conflict_score, mode,
):
"""Single-source the ring-loud PresentationReview row shape so the two writers
(system-tag sweep guard-2 + soft-wip audit) can't drift on columns or `mode` —
they share the (image_record_id, tag_id) composite PK, so a divergent `mode`
would be a silent first-writer-wins bug."""
session.execute(
pg_insert(PresentationReview)
.values(
image_record_id=image_record_id, tag_id=tag_id,
conflict_tag_id=conflict_tag_id, conflict_score=conflict_score,
mode=mode,
)
.on_conflict_do_nothing()
)
class HeadTrainingAlreadyRunning(Exception):
"""Raised by start_head_training_run when a run is already in flight."""
@@ -103,9 +145,7 @@ def start_head_training_run(session: Session, params: dict[str, Any]) -> int:
def _settings(session: Session) -> MLSettings:
return session.execute(
select(MLSettings).where(MLSettings.id == 1)
).scalar_one()
return MLSettings.load_sync(session)
def _normalize_params(session: Session, params: dict[str, Any] | None) -> dict[str, Any]:
@@ -124,7 +164,7 @@ def _normalize_params(session: Session, params: dict[str, Any] | None) -> dict[s
except (TypeError, ValueError):
cv_folds = DEFAULT_CV_FOLDS
try:
precision_target = min(max(float(params.get("precision_target", s.head_auto_apply_precision)), 0.5), 0.999)
precision_target = min(max(float(params.get("precision_target", s.head_auto_apply_precision)), AUTO_APPLY_THRESHOLD_MIN), AUTO_APPLY_THRESHOLD_MAX)
except (TypeError, ValueError):
precision_target = s.head_auto_apply_precision
return {
@@ -536,7 +576,7 @@ async def score_image(
norms[norms == 0] = 1.0
Xn = X / norms
Z = Xn @ heads["W"].T + heads["b"] # (B, H)
probs_bag = 1.0 / (1.0 + np.exp(-Z)) # (B, H)
probs_bag = _sigmoid(Z, np) # (B, H)
probs = probs_bag.max(axis=0) # (H,) best over the bag
# ARGMAX beside the max: WHICH bag row won each head → the region that grounds
# the tag (bag_meta[win]); None when the whole-image vector won (#1206).
@@ -614,9 +654,7 @@ async def ground_applied_tag(
async def _settings_async(session: AsyncSession) -> MLSettings:
return (
await session.execute(select(MLSettings).where(MLSettings.id == 1))
).scalar_one()
return await MLSettings.load(session)
# --- Earned auto-apply (sync, ml worker) ---------------------------------
@@ -687,7 +725,6 @@ def auto_apply_sweep(
embeddings in chunks; commits per chunk on a real run. Returns
{n_applied, concepts:[{tag_id,name,applied,scanned,threshold}]}."""
import numpy as np
from sqlalchemy.dialects.postgresql import insert as pg_insert
settings = _settings(session)
rows = _auto_apply_heads(
@@ -704,18 +741,7 @@ def auto_apply_sweep(
names = [r.name for r in rows]
# Skip images that already carry, or have rejected, each tag.
skip = {tid: set() for tid in tag_ids}
for tid in tag_ids:
for (iid,) in session.execute(
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
):
skip[tid].add(iid)
for (iid,) in session.execute(
select(TagSuggestionRejection.image_record_id).where(
TagSuggestionRejection.tag_id == tid
)
):
skip[tid].add(iid)
skip = _applied_or_rejected(session, tag_ids)
applied = [0] * len(rows)
scanned = 0
@@ -729,7 +755,7 @@ def auto_apply_sweep(
if not cids:
continue
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
probs = 1.0 / (1.0 + np.exp(-(Xn @ W.T + b))) # (N, H)
probs = _sigmoid(Xn @ W.T + b, np) # (N, H)
scanned += len(cids)
for h in range(len(rows)):
tid = tag_ids[h]
@@ -759,18 +785,42 @@ def auto_apply_sweep(
_PRESENTATION_SOURCE = "presentation_auto"
_PROCESS_SOURCE = "process_auto"
# System-tag auto-apply modes (#1464). Both modes run the identical sweep — apply
# a system tag at a flat threshold with a PROVISIONAL source + a ring-loud review
# guard — and differ ONLY in which tags, which settings knobs, and which
# source/review-mode. 'chrome' (banner) is HIDDEN from the gallery; 'process'
# (wip / editor screenshot) stays VISIBLE (the hide is a gallery-query effect of
# the tag's group membership, not of this sweep).
_SWEEP_MODES = {
"chrome": {
"names": CHROME_SYSTEM_TAGS,
"enabled": "presentation_auto_apply_enabled",
"threshold": "presentation_auto_apply_threshold",
"conflict": "presentation_conflict_threshold",
"source": _PRESENTATION_SOURCE,
},
"process": {
"names": PROCESS_SYSTEM_TAGS,
"enabled": "process_auto_apply_enabled",
"threshold": "process_auto_apply_threshold",
"conflict": "process_conflict_threshold",
"source": _PROCESS_SOURCE,
},
}
def _presentation_heads(session: Session, embedding_version: str):
"""Trained heads for the presentation chrome tags (banner / editor screenshot).
They fire at the FLAT presentation threshold regardless of graduation — a head
exists once the operator has labelled enough chrome (head_min_positives)."""
def _system_tag_heads(session: Session, embedding_version: str, names):
"""Trained heads for a system-tag group (chrome banner / process wip+editor).
They fire at the group's FLAT threshold regardless of graduation — a head
exists once the operator has labelled enough (head_min_positives)."""
return session.execute(
select(TagHead.tag_id, Tag.name, TagHead.weights, TagHead.bias)
.join(Tag, Tag.id == TagHead.tag_id)
.where(TagHead.embedding_version == embedding_version)
.where(Tag.is_system.is_(True))
.where(Tag.name.in_(PRESENTATION_SYSTEM_TAGS))
.where(Tag.name.in_(names))
).all()
@@ -802,27 +852,32 @@ def _valued_image_ids(session: Session) -> set[int]:
return {r[0] for r in rows}
def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> dict:
"""Auto-hide presentation chrome (banner / editor screenshot) at the FLAT
presentation threshold (#141) — NOT the per-head graduated threshold. Two
guards keep it safe: (1) never hide an image carrying a human/confirmed content
tag; (2) if an image about to be hidden ALSO scores >= the conflict threshold
on a content head, still hide it but flag it (PresentationReview) so the Hidden
view surfaces "also looks like <X>" for review. No-op unless
presentation_auto_apply_enabled. numpy-only (no sklearn). Returns
{n_applied, n_flagged, concepts}."""
def system_tag_auto_apply_sweep(
session: Session, *, mode: str, dry_run: bool = False
) -> dict:
"""Auto-apply a system-tag group at its FLAT threshold. mode='chrome' (banner,
#141) hides the image; mode='process' (wip / editor screenshot, #1464) keeps it
VISIBLE — the ONLY difference is the tag group's gallery membership, not this
sweep. Two guards keep it safe: (1) never touch an image carrying a
human/confirmed content tag; (2) if the image ALSO scores >= the conflict
threshold on a content head, still apply but flag it (PresentationReview,
mode=<mode>) so the review strip surfaces "also looks like <X>". The source is
PROVISIONAL so the head never trains on its own output. No-op unless the mode's
enabled flag is set. numpy-only (no sklearn). Returns {n_applied, n_flagged,
concepts}."""
import numpy as np
from sqlalchemy.dialects.postgresql import insert as pg_insert
cfg = _SWEEP_MODES[mode]
settings = _settings(session)
if not dry_run and not settings.presentation_auto_apply_enabled:
if not dry_run and not getattr(settings, cfg["enabled"]):
return {"n_applied": 0, "n_flagged": 0, "concepts": []}
ver = settings.embedder_model_version
pres = _presentation_heads(session, ver)
pres = _system_tag_heads(session, ver, cfg["names"])
if not pres:
return {"n_applied": 0, "n_flagged": 0, "concepts": []}
thr = float(settings.presentation_auto_apply_threshold)
conflict_thr = float(settings.presentation_conflict_threshold)
thr = float(getattr(settings, cfg["threshold"]))
conflict_thr = float(getattr(settings, cfg["conflict"]))
source = cfg["source"]
Wp = np.vstack([np.asarray(r.weights, dtype=np.float32) for r in pres])
bp = np.asarray([r.bias for r in pres], dtype=np.float32)
@@ -839,18 +894,7 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
valued = _valued_image_ids(session)
# Skip images that already carry, or have rejected, each presentation tag.
skip = {tid: set() for tid in pres_tag_ids}
for tid in pres_tag_ids:
for (iid,) in session.execute(
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
):
skip[tid].add(iid)
for (iid,) in session.execute(
select(TagSuggestionRejection.image_record_id).where(
TagSuggestionRejection.tag_id == tid
)
):
skip[tid].add(iid)
skip = _applied_or_rejected(session, pres_tag_ids)
applied = [0] * len(pres)
n_flagged = 0
@@ -865,11 +909,9 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
if not cids:
continue
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
probs = 1.0 / (1.0 + np.exp(-(Xn @ Wp.T + bp))) # (N, P)
probs = _sigmoid(Xn @ Wp.T + bp, np) # (N, P)
if Wc is not None:
cprobs = 1.0 / (1.0 + np.exp(-(Xn @ Wc.T + bc))) # (N, C)
max_c = cprobs.max(axis=1)
arg_c = cprobs.argmax(axis=1)
max_c, arg_c = _conflict_scores(Xn, Wc, bc, np) # (N,), (N,)
scanned += len(cids)
for p in range(len(pres)):
tid = pres_tag_ids[p]
@@ -884,22 +926,22 @@ def presentation_auto_apply_sweep(session: Session, dry_run: bool = False) -> di
pg_insert(image_tag)
.values(
image_record_id=iid, tag_id=tid,
source=_PRESENTATION_SOURCE,
source=source,
)
.on_conflict_do_nothing()
)
# Guard 2: also looks like content → hide but flag for review.
# Guard 2: also looks like real content → still apply, but flag it
# for the review strip instead of silently marking (chrome hides,
# process stays visible — either way the operator gets a heads-up).
if Wc is not None and float(max_c[idx]) >= conflict_thr:
n_flagged += 1
if not dry_run:
session.execute(
pg_insert(PresentationReview)
.values(
image_record_id=iid, tag_id=tid,
conflict_tag_id=conf_tag_ids[int(arg_c[idx])],
conflict_score=float(max_c[idx]),
)
.on_conflict_do_nothing()
_insert_presentation_review(
session,
image_record_id=iid, tag_id=tid,
conflict_tag_id=conf_tag_ids[int(arg_c[idx])],
conflict_score=float(max_c[idx]),
mode=mode,
)
if not dry_run:
session.commit()
@@ -914,6 +956,68 @@ 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 ..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)
max_c, arg_c = _conflict_scores(Xn, Wc, bc, np)
for k in range(len(cids)):
if float(max_c[k]) >= conflict_thr:
n_flagged += 1
if not dry_run:
_insert_presentation_review(
session,
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",
)
if not dry_run:
session.commit()
return {"n_scanned": scanned, "n_flagged": n_flagged}
def retract_auto_applied_heads(session: Session) -> int:
"""Soft auto-apply (milestone 139): re-score every standing source='head_auto'
tag against its CURRENT head and REMOVE the ones now BELOW the head's
@@ -961,7 +1065,7 @@ def retract_auto_applied_heads(session: Session) -> int:
continue
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
w = np.asarray(weights, dtype=np.float32)
probs = 1.0 / (1.0 + np.exp(-(Xn @ w + float(bias))))
probs = _sigmoid(Xn @ w + float(bias), np)
below = [cids[k] for k in np.where(probs < float(thr))[0]]
for iid in below:
session.execute(
+27 -1
View File
@@ -29,7 +29,15 @@ from ...models.tag import image_tag
# a CCIP reference) unless the operator confirms them (milestone 139). Keeping
# auto-applied predictions out of training is what makes them "soft" — a misfire
# can't reinforce itself, so the retraction sweep can actually drop it.
_AUTO_SOURCES = ("head_auto", "ccip_auto", "ml_auto", "presentation_auto")
# `process_auto` (#1464): wip/editor screenshot applied by the process sweep are
# ALSO provisional — the head must learn only from title (`wip_title`) + manual
# labels, never its own auto-applied output, or it would runaway (operator 2026-07-12).
# `wip_title_soft` (#1474): the soft title tier (sketch/doodle) is LOW-precision, so
# it's provisional too — a finished piece titled "sketch" must not train the wip head.
_AUTO_SOURCES = (
"head_auto", "ccip_auto", "ml_auto", "presentation_auto", "process_auto",
"wip_title_soft",
)
def _hygiene_excluded_ids(session: Session) -> set[int]:
@@ -86,6 +94,24 @@ def _rejected_ids(session: Session, tag_id: int) -> list[int]:
]
def _applied_or_rejected(session: Session, tag_ids) -> dict[int, set[int]]:
"""Per-tag skip set for the auto-apply sweeps: every image that ALREADY carries
the tag (ANY source — not just training positives) OR has rejected it. A sweep
never re-applies to these. Shared by auto_apply_sweep + system_tag_auto_apply_sweep
(heads.py) and scheduled_ccip_auto_apply (tasks/ml.py). Callers mutate the returned
sets in-place to also dedupe within a single run."""
skip: dict[int, set[int]] = {}
for tid in tag_ids:
ids = {
r[0] for r in session.execute(
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
).all()
}
ids.update(_rejected_ids(session, tid))
skip[tid] = ids
return skip
def _sample_unlabeled(session: Session, exclude: set[int], limit: int) -> list[int]:
"""Random image ids (with an embedding) NOT carrying the tag. Concepts are
sparse, so an untagged image is almost always a true negative."""
+20 -36
View File
@@ -91,48 +91,46 @@ def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None:
)
def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
def _campaigns_api_first(vanity: str, cookies_path: str | None) -> dict | None:
"""The first `data` object from Patreon's campaigns API filtered by vanity
(`?filter[vanity]=<vanity>&fields[campaign]=name`), or None on any failure
(network / non-200 / non-JSON / empty). The single request shape shared by
_lookup_via_api (plucks the campaign id) and resolve_display_name (plucks the
display name)."""
jar = _load_cookie_jar(cookies_path)
headers = {
"User-Agent": _USER_AGENT,
"Accept": "application/vnd.api+json",
}
params = {
"filter[vanity]": vanity,
"fields[campaign]": "name",
}
try:
resp = requests.get(
_CAMPAIGNS_URL,
params=params,
headers=headers,
params={"filter[vanity]": vanity, "fields[campaign]": "name"},
headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"},
cookies=jar,
timeout=_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
log.warning("Patreon campaigns API request failed for vanity=%s: %s", vanity, exc)
return None
if resp.status_code != 200:
log.warning(
"Patreon campaigns API returned HTTP %d for vanity=%s",
resp.status_code, vanity,
)
return None
try:
payload = resp.json()
except ValueError as exc:
log.warning("Patreon campaigns API returned non-JSON for vanity=%s: %s", vanity, exc)
return None
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
return None
return data[0]
if not isinstance(payload, dict):
def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
first = _campaigns_api_first(vanity, cookies_path)
if first is None:
return None
data = payload.get("data")
if not isinstance(data, list) or not data:
return None
first = data[0] if isinstance(data[0], dict) else None
campaign_id = first.get("id") if first else None
campaign_id = first.get("id")
if not isinstance(campaign_id, str) or not campaign_id:
return None
log.info("Resolved Patreon vanity=%s → campaign_id=%s", vanity, campaign_id)
@@ -144,24 +142,10 @@ def resolve_display_name(vanity: str, cookies_path: str | None) -> str | None:
(`fields[campaign]=name`), used to name the Artist at add-time (#130). None
on any failure — the caller falls back to the vanity handle. Sync: call from
an executor."""
jar = _load_cookie_jar(cookies_path)
try:
resp = requests.get(
_CAMPAIGNS_URL,
params={"filter[vanity]": vanity, "fields[campaign]": "name"},
headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"},
cookies=jar,
timeout=_TIMEOUT_SECONDS,
)
if resp.status_code != 200:
return None
data = resp.json().get("data")
except (requests.RequestException, ValueError) as exc:
log.warning("Patreon name lookup failed for vanity=%s: %s", vanity, exc)
first = _campaigns_api_first(vanity, cookies_path)
if first is None:
return None
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
return None
name = (data[0].get("attributes") or {}).get("name")
name = (first.get("attributes") or {}).get("name")
return name.strip() if isinstance(name, str) and name.strip() else None
+125
View File
@@ -0,0 +1,125 @@
"""Title-based WIP auto-tagging (task #1458).
Deterministic heuristic: when a post's TITLE explicitly declares work-in-progress
(the artist's own "WIP" / "work in progress" label), the ``wip`` system tag is
applied to that post's images — a cheap, high-precision complement to the
image-based ML ``wip`` head. WIP images are excluded from the Explore/gallery
browse (see gallery_service ``excluded_system_tags``), so honouring the artist's
own label keeps unfinished pieces out of the main browse right at import.
Precision over recall — a false WIP tag HIDES a finished post — so matching is
token-anchored: ``swipe`` / ``wiped`` / ``wiping`` never trip it (a letter on the
boundary blocks the match).
Sync-only: both consumers (the importer and the backfill Celery task) run on a
sync Session. Application is idempotent-additive (ON CONFLICT DO NOTHING) and
stamps a distinct ``image_tag.source`` so a later pass can tell where a wip tag
came from — the "manual" / "head_auto" / "ccip_auto" / "ml_accepted" provenance
family gains one member.
"""
import re
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from ..models.tag import WIP_SYSTEM_TAG, Tag, image_tag
# image_tag.source stamped on title-heuristic WIP tags — distinct from the other
# apply sources so provenance stays legible and a future undo can target only these.
# HARD tier ("WIP"/"work in progress") is high-precision → trains the wip head.
WIP_TITLE_SOURCE = "wip_title"
# SOFT tier (sketch/doodle/scribble, #1474) is LOWER-precision — a finished "sketch"
# is often not WIP. This source is PROVISIONAL (in training_data._AUTO_SOURCES) so it
# NEVER trains the wip head; a soft-tagged image that also looks like real content is
# surfaced by the ring-loud audit for review.
WIP_TITLE_SOFT_SOURCE = "wip_title_soft"
# A standalone "WIP" / "W.I.P" token, or the phrase "work in progress"
# (space/underscore/hyphen separated). The letter-boundary lookarounds are what
# make this precision-first: `s|wip|e`, `|wip|ed`, `|wip|ing` all have a letter
# abutting the token, so they're rejected. A trailing digit is allowed so
# "WIP2" (= WIP part 2) still matches.
_WIP_RE = re.compile(
r"(?<![A-Za-z])(?:w\.?i\.?p\.?|work[\s_-]+in[\s_-]+progress)(?![A-Za-z])",
re.IGNORECASE,
)
# 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.
# Each MUST stay a SUPERSET of its regex or the sweep would silently miss posts.
WIP_TITLE_SQL_PREFILTER = ("%wip%", "%work%progress%")
SOFT_WIP_TITLE_SQL_PREFILTER = ("%sketch%", "%doodle%", "%scribble%")
# Chunk bulk inserts so a large sweep can't blow past psycopg's 65535-parameter
# ceiling (3 params/row → ~21k rows max; 5k stays comfortably under).
_INSERT_CHUNK = 5000
def matches_wip_title(title: str | None) -> bool:
"""True when a post title explicitly marks it work-in-progress (HARD tier)."""
if not title:
return False
return _WIP_RE.search(title) is not None
def matches_soft_wip_title(title: str | None) -> bool:
"""True when a title carries a SOFT WIP cue (sketch/doodle/scribble, #1474)."""
if not title:
return False
return _SOFT_WIP_RE.search(title) is not None
def resolve_wip_tag_id(session: Session) -> int | None:
"""The seeded ``wip`` system tag's id (migration 0075), or None if absent."""
return session.execute(
select(Tag.id).where(Tag.name == WIP_SYSTEM_TAG, Tag.is_system.is_(True))
).scalar_one_or_none()
def apply_wip_image_tags(
session: Session, image_ids, tag_id: int, *, source: str = WIP_TITLE_SOURCE
) -> int:
"""Attach ``tag_id`` (stamped with ``source``) to each image id, idempotently —
never disturbs an existing tag or its source. Returns the number of image_tag
rows newly inserted. Does NOT commit.
The insert count is computed from a pre-SELECT of already-tagged ids rather
than the statement's ``rowcount``: psycopg reports -1 for a multi-row
ON CONFLICT DO NOTHING insert (it runs via an executemany path), so rowcount
is unusable here. The SELECT is accurate within this single transaction (no
concurrent writer touches these (image, wip) rows); ON CONFLICT DO NOTHING
stays as a race-safety belt so a rare concurrent insert can't error."""
ids = list({int(i) for i in image_ids})
if not ids:
return 0
inserted = 0
for start in range(0, len(ids), _INSERT_CHUNK):
chunk = ids[start:start + _INSERT_CHUNK]
already = set(session.execute(
select(image_tag.c.image_record_id)
.where(image_tag.c.tag_id == tag_id)
.where(image_tag.c.image_record_id.in_(chunk))
).scalars())
to_insert = [iid for iid in chunk if iid not in already]
if not to_insert:
continue
session.execute(
pg_insert(image_tag)
.values([
{"image_record_id": iid, "tag_id": tag_id, "source": source}
for iid in to_insert
])
.on_conflict_do_nothing(index_elements=["image_record_id", "tag_id"])
)
inserted += len(to_insert)
return inserted
+137 -72
View File
@@ -76,6 +76,8 @@ DOWNLOAD_STALL_THRESHOLD_MINUTES = 30
OLD_TASK_DAYS = 7
PHASH_PAGE = 500
VERIFY_PAGE = 200
# Title-based WIP backfill (task #1458): posts scanned per keyset page.
WIP_BACKFILL_PAGE = 500
FFPROBE_TIMEOUT_SECONDS = 10
TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h
TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
@@ -774,89 +776,62 @@ def recover_stalled_library_audit_runs() -> int:
return recovered
def _recover_stalled_runs(model, *, stall_minutes: int, keep_runs: int, label: str) -> int:
"""Shared recovery + retention sweep for the head run-tracking tables
(HeadTrainingRun / HeadAutoApplyRun, which share the
status/last_progress_at/started_at/finished_at/error/id columns): flip 'running'
rows with no progress past `stall_minutes` to 'error', then prune to the last
`keep_runs` (rule 89). Returns the number recovered. NOTE the two other recover
tasks are deliberately NOT folded in — library-audit has no prune tail and
backup uses a single started_at cutoff."""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
cutoff = now - timedelta(minutes=stall_minutes)
with SessionLocal() as session:
result = session.execute(
update(model)
.where(model.status == "running")
.where(func.coalesce(model.last_progress_at, model.started_at) < cutoff)
.values(
status="error", finished_at=now,
error=f"stranded by recovery sweep (no progress for {stall_minutes} min)",
)
)
keep = session.execute(
select(model.id).order_by(model.id.desc()).limit(keep_runs)
).scalars().all()
if keep:
session.execute(delete(model).where(model.id.not_in(keep)))
session.commit()
recovered = result.rowcount or 0
if recovered:
log.info("%s: recovered %d rows", label, recovered)
return recovered
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_training_runs")
def recover_stalled_head_training_runs() -> int:
"""Flip HeadTrainingRun rows stuck in 'running' past the stall threshold to
'error', and prune old runs to the last HEAD_TRAINING_KEEP_RUNS (retention,
rule 89). Runs every 5 min on the maintenance lane; no-op when idle."""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
cutoff = now - timedelta(minutes=HEAD_TRAINING_STALL_THRESHOLD_MINUTES)
with SessionLocal() as session:
result = session.execute(
update(HeadTrainingRun)
.where(HeadTrainingRun.status == "running")
.where(
func.coalesce(
HeadTrainingRun.last_progress_at, HeadTrainingRun.started_at
)
< cutoff
)
.values(
status="error", finished_at=now,
error=(
f"stranded by recovery sweep (no progress for "
f"{HEAD_TRAINING_STALL_THRESHOLD_MINUTES} min)"
),
)
)
keep = session.execute(
select(HeadTrainingRun.id).order_by(HeadTrainingRun.id.desc())
.limit(HEAD_TRAINING_KEEP_RUNS)
).scalars().all()
if keep:
session.execute(
delete(HeadTrainingRun).where(HeadTrainingRun.id.not_in(keep))
)
session.commit()
recovered = result.rowcount or 0
if recovered:
log.info(
"recover_stalled_head_training_runs: recovered %d rows", recovered
)
return recovered
return _recover_stalled_runs(
HeadTrainingRun,
stall_minutes=HEAD_TRAINING_STALL_THRESHOLD_MINUTES,
keep_runs=HEAD_TRAINING_KEEP_RUNS,
label="recover_stalled_head_training_runs",
)
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_auto_apply_runs")
def recover_stalled_head_auto_apply_runs() -> int:
"""Flip stalled HeadAutoApplyRun 'running' rows to 'error' + prune to the
last HEAD_AUTO_APPLY_KEEP_RUNS (retention, rule 89). 5-min maintenance lane."""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
cutoff = now - timedelta(minutes=HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES)
with SessionLocal() as session:
result = session.execute(
update(HeadAutoApplyRun)
.where(HeadAutoApplyRun.status == "running")
.where(
func.coalesce(
HeadAutoApplyRun.last_progress_at, HeadAutoApplyRun.started_at
)
< cutoff
)
.values(
status="error", finished_at=now,
error=(
f"stranded by recovery sweep (no progress for "
f"{HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES} min)"
),
)
)
keep = session.execute(
select(HeadAutoApplyRun.id).order_by(HeadAutoApplyRun.id.desc())
.limit(HEAD_AUTO_APPLY_KEEP_RUNS)
).scalars().all()
if keep:
session.execute(
delete(HeadAutoApplyRun).where(HeadAutoApplyRun.id.not_in(keep))
)
session.commit()
recovered = result.rowcount or 0
if recovered:
log.info(
"recover_stalled_head_auto_apply_runs: recovered %d rows", recovered
)
return recovered
return _recover_stalled_runs(
HeadAutoApplyRun,
stall_minutes=HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES,
keep_runs=HEAD_AUTO_APPLY_KEEP_RUNS,
label="recover_stalled_head_auto_apply_runs",
)
# Keep ~6 months of daily head-metric snapshots (enough to see tuning trends).
@@ -1045,6 +1020,96 @@ def cleanup_old_download_events() -> int:
return result.rowcount or 0
def _backfill_wip_tier(session, tag_id, prefilter, matcher, source) -> int:
"""One keyset-paginated pass over posts whose title matches a WIP tier, applying
`tag_id` (stamped `source`) to their images. Shared by the hard + soft tiers
(#1458 / #1474). Coarse `prefilter` (ILIKE superset) narrows the scan; the precise
`matcher` confirms. Idempotent-additive (ON CONFLICT DO NOTHING). Returns the row
count newly applied."""
from ..models import Post
from ..models.image_provenance import ImageProvenance
from ..services.wip_title import apply_wip_image_tags
applied = 0
last_id = 0
while True:
rows = session.execute(
select(Post.id, Post.post_title)
.where(Post.id > last_id)
.where(Post.post_title.is_not(None))
.where(or_(*[Post.post_title.ilike(p) for p in prefilter]))
.order_by(Post.id.asc())
.limit(WIP_BACKFILL_PAGE)
).all()
if not rows:
break
last_id = rows[-1][0]
match_ids = [pid for pid, title in rows if matcher(title)]
if match_ids:
image_ids = session.execute(
select(ImageProvenance.image_record_id)
.where(ImageProvenance.post_id.in_(match_ids))
).scalars().all()
applied += apply_wip_image_tags(session, image_ids, tag_id, source=source)
session.commit()
return applied
@celery.task(
name="backend.app.tasks.maintenance.backfill_wip_title_tags",
# Coarse-prefiltered scan over posts; the candidate set is small on a typical
# library, but bound it like the other full-library sweeps.
soft_time_limit=1800, time_limit=2100,
)
def backfill_wip_title_tags() -> int:
"""Scan EXISTING posts for WIP titles and apply the `wip` system tag to their
images — the operator-triggered back-catalogue catch-up (task #1458 hard tier +
#1474 soft tier). New imports are tagged live by the importer; this covers the
existing library.
HARD tier ("WIP"/"work in progress") always runs (the operator triggered the
scan); the SOFT tier (sketch/doodle, provisional source) runs only when
wip_soft_title_tagging_enabled, AFTER hard so a title matching both keeps the
trained hard tag (ON CONFLICT DO NOTHING). Keyset-paginated, restart-safe.
Deliberately NOT scheduled as a beat: a periodic re-run would re-apply to matching
posts and silently undo a manual WIP removal, so it stays an explicit operator
action (Settings → "Scan existing posts for WIP titles"). Returns rows applied.
"""
from ..models import ImportSettings
from ..services.wip_title import (
SOFT_WIP_TITLE_SQL_PREFILTER,
WIP_TITLE_SOFT_SOURCE,
WIP_TITLE_SOURCE,
WIP_TITLE_SQL_PREFILTER,
matches_soft_wip_title,
matches_wip_title,
resolve_wip_tag_id,
)
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
tag_id = resolve_wip_tag_id(session)
if tag_id is None:
log.warning(
"backfill_wip_title_tags: no `wip` system tag present; nothing to do"
)
return 0
settings = ImportSettings.load_sync(session)
applied = _backfill_wip_tier(
session, tag_id, WIP_TITLE_SQL_PREFILTER, matches_wip_title,
WIP_TITLE_SOURCE,
)
if settings.wip_soft_title_tagging_enabled:
applied += _backfill_wip_tier(
session, tag_id, SOFT_WIP_TITLE_SQL_PREFILTER, matches_soft_wip_title,
WIP_TITLE_SOFT_SOURCE,
)
if applied:
log.info("backfill_wip_title_tags: applied wip to %d image(s)", applied)
return applied
@celery.task(name="backend.app.tasks.maintenance.vacuum_analyze")
def vacuum_analyze() -> dict:
"""Periodic VACUUM (ANALYZE) over the high-churn tables (VACUUM_TABLES) to
+52 -36
View File
@@ -105,9 +105,7 @@ def embed_image(self, image_id: int) -> dict:
record = session.get(ImageRecord, image_id)
if record is None:
return {"status": "missing", "image_id": image_id}
settings = session.execute(
select(MLSettings).where(MLSettings.id == 1)
).scalar_one()
settings = MLSettings.load_sync(session)
src = Path(record.path)
is_vid = _is_video(src)
@@ -488,15 +486,10 @@ def scheduled_ccip_auto_apply() -> str:
from sqlalchemy import select as sa_select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from ..models import ImageRegion, MLSettings, Tag, TagKind, TagSuggestionRejection
from ..models import ImageRegion, MLSettings, Tag, TagKind
from ..models.tag import image_tag
fig = ("face", "figure")
def _l2(m):
n = np.linalg.norm(m, axis=1, keepdims=True)
n[n == 0] = 1.0
return m / n
from ..services.ml.ccip import _FIGURE_KINDS
from ..services.ml.training_data import _applied_or_rejected, _l2norm
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
@@ -521,7 +514,7 @@ def scheduled_ccip_auto_apply() -> str:
)
.join(Tag, Tag.id == image_tag.c.tag_id)
.where(Tag.kind == TagKind.character)
.where(ImageRegion.kind.in_(fig))
.where(ImageRegion.kind.in_(_FIGURE_KINDS))
.where(ImageRegion.ccip_embedding.is_not(None))
.where(ImageRegion.image_record_id.in_(single))
).all()
@@ -532,29 +525,16 @@ def scheduled_ccip_auto_apply() -> str:
for tid, vec in ref_rows:
by_char.setdefault(tid, []).append(vec)
ref_tags = list(by_char)
mats = [_l2(np.asarray(by_char[t], dtype=np.float32)) for t in ref_tags]
mats = [_l2norm(np.asarray(by_char[t], dtype=np.float32), np) for t in ref_tags]
allref = np.vstack(mats) # (total, 768)
seg = np.cumsum([0] + [len(m) for m in mats])[:-1] # per-char start
# Per character: images that already carry OR rejected the tag — skip.
skip = {t: set() for t in ref_tags}
for t in ref_tags:
for (iid,) in session.execute(
sa_select(image_tag.c.image_record_id).where(
image_tag.c.tag_id == t
)
):
skip[t].add(iid)
for (iid,) in session.execute(
sa_select(TagSuggestionRejection.image_record_id).where(
TagSuggestionRejection.tag_id == t
)
):
skip[t].add(iid)
skip = _applied_or_rejected(session, ref_tags)
img_ids = list(session.execute(
sa_select(ImageRegion.image_record_id)
.where(ImageRegion.kind.in_(fig), ImageRegion.ccip_embedding.is_not(None))
.where(ImageRegion.kind.in_(_FIGURE_KINDS), ImageRegion.ccip_embedding.is_not(None))
.distinct()
).scalars())
@@ -566,7 +546,7 @@ def scheduled_ccip_auto_apply() -> str:
sa_select(ImageRegion.image_record_id, ImageRegion.ccip_embedding)
.where(
ImageRegion.image_record_id.in_(chunk),
ImageRegion.kind.in_(fig),
ImageRegion.kind.in_(_FIGURE_KINDS),
ImageRegion.ccip_embedding.is_not(None),
)
).all()
@@ -574,7 +554,7 @@ def scheduled_ccip_auto_apply() -> str:
for iid, vec in rows:
by_img.setdefault(iid, []).append(vec)
for iid, vecs in by_img.items():
q = _l2(np.asarray(vecs, dtype=np.float32)) # (nq, 768)
q = _l2norm(np.asarray(vecs, dtype=np.float32), np) # (nq, 768)
colmax = (q @ allref.T).max(axis=0) # (total,)
charmax = np.maximum.reduceat(colmax, seg) # (n_chars,)
for ci in np.where(charmax >= thr)[0]:
@@ -599,18 +579,54 @@ def scheduled_ccip_auto_apply() -> str:
soft_time_limit=1800, time_limit=2100,
)
def scheduled_presentation_auto_apply() -> str:
"""Auto-hide presentation chrome (banner / editor screenshot) on a daily
passive sweep (#141). No-op unless presentation_auto_apply_enabled. Idempotent
— already-hidden images are skipped — so an interrupted run simply re-runs next
cycle (that IS the recovery). Wall-clock bounded by the task time limits."""
from ..services.ml.heads import presentation_auto_apply_sweep
"""Auto-hide presentation chrome (banner) on a daily passive sweep (#141).
No-op unless presentation_auto_apply_enabled. Idempotent — already-tagged images
are skipped — so an interrupted run simply re-runs next cycle (that IS the
recovery). Wall-clock bounded by the task time limits."""
from ..services.ml.heads import system_tag_auto_apply_sweep
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
result = presentation_auto_apply_sweep(session)
result = system_tag_auto_apply_sweep(session, mode="chrome")
return f"applied={result['n_applied']} flagged={result['n_flagged']}"
@celery.task(
name="backend.app.tasks.ml.scheduled_process_auto_apply",
soft_time_limit=1800, time_limit=2100,
)
def scheduled_process_auto_apply() -> str:
"""Auto-apply the PROCESS system tags (wip / editor screenshot) on a daily
passive sweep (#1464) — provisional source, ring-loud review guard, image stays
VISIBLE. No-op unless process_auto_apply_enabled (opt-in). Idempotent —
already-tagged/rejected images are skipped — so an interrupted run just re-runs
next cycle (the recovery). Wall-clock bounded by the task time limits."""
from ..services.ml.heads import system_tag_auto_apply_sweep
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
result = system_tag_auto_apply_sweep(session, mode="process")
return f"applied={result['n_applied']} flagged={result['n_flagged']}"
@celery.task(
name="backend.app.tasks.ml.scheduled_soft_wip_conflict_audit",
soft_time_limit=1800, time_limit=2100,
)
def scheduled_soft_wip_conflict_audit() -> str:
"""Ring-loud audit over the SOFT WIP-title cohort (#1474) — flag sketch/doodle
auto-tags that ALSO look like real content for review. No-op when there are no
content heads; idempotent (already-flagged images skipped). Runs regardless of
the process-sweep toggle, since soft-title tags come from the importer, not that
sweep. Wall-clock bounded by the task time limits."""
from ..services.ml.heads import soft_wip_conflict_audit
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
result = soft_wip_conflict_audit(session)
return f"scanned={result['n_scanned']} flagged={result['n_flagged']}"
@celery.task(name="backend.app.tasks.ml.prune_presentation_reviews")
def prune_presentation_reviews() -> str:
"""Retention (rule 89): drop RESOLVED presentation-review flags older than 30
+21 -3
View File
@@ -11,12 +11,22 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
- python 3.14
- ruff (analyzer for `backend/`, `tests/`, `alembic/`)
- node (frontend job: `npm install` + vitest + vite build)
- docker CLI + buildx (`.forgejo/workflows/build.yml`: build-web, build-ml — Forgejo registry push)
- docker CLI + buildx (`.forgejo/workflows/build.yml`: build-web, build-ml — Fabled-Git registry push)
## Secondary runtime image
node:24-bookworm-slim — `.forgejo/workflows/extension.yml` only.
The extension lane is the one job that does NOT run on `ci-python:3.14`: it
needs a current Node for `web-ext` and vitest and nothing Python at all. Kept
on the upstream slim image rather than adding a Node toolchain to `ci-python`,
per `docs/process.md`'s "add deps to the image when used by >1 project".
## Per-job tool installs
- `pip install -r requirements.txt pytest pytest-asyncio` — in `backend-lint-and-test` and `integration` jobs
- `npm install --no-audit --no-fund` — in `frontend-build` job
- `npm install --no-audit --no-fund` — in `extension.yml`'s `lint` job (web-ext + vitest)
## Notes
@@ -26,12 +36,20 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
"add deps to image when used by >1 project" rule: FC alone is one Python
project, so the deps live in `requirements.txt` and install per-job.
Reconsider when a second Fabled-family Python backend lands.
- Integration uses Forgejo Actions `services:` + socket-discovered bridge IPs
- Integration uses Fabled-Git Actions `services:` + socket-discovered bridge IPs
because `act_runner` (swarm-runner v0.6+) puts services on the default
bridge with no embedded DNS. The pattern is documented in the rulebook's
`forgejo.md` "CI philosophy" section and FC's `ci.yml` is the canonical
`fabled-git.md` "CI philosophy" section and FC's `ci.yml` is the canonical
example.
- No `package-lock.json` is tracked yet (FC's `feedback_no_local_runs`
memory bans `npm install` locally). Using `npm install` rather than
`npm ci` until a lockfile lands.
- No `imagemagick` / `pandoc` per-job installs needed.
- `extension/`'s vitest specs load `lib/*.js` by evaluating the real file as a
classic script (`test/helpers/loadLib.js`) rather than adding `module.exports`
shims to production code — the libs ship as `background.scripts`, not ES
modules, so the specs exercise exactly the bytes packaged into the XPI.
- Extension test files are excluded from the XPI via `--ignore-files` in
`extension/package.json`, and the same paths are excluded from `ci.yml`'s
`extension-version` guard. Those two lists must agree — `test/version.spec.js`
asserts the guard never ignores a file web-ext actually packages.
+92 -30
View File
@@ -31,6 +31,68 @@ browser.runtime.onInstalled.addListener(() => ensureInitialized());
browser.runtime.onStartup.addListener(() => ensureInitialized());
ensureInitialized().catch(e => console.error('init failed:', e));
// ---- Extension self-update check (#1489) ----
// Installed per-instance from the operator's FC host, so Firefox's static
// update_url can't apply (each instance has a different host). Instead ask the
// configured backend for the latest published version and nudge the operator to
// reinstall the freshly-signed XPI — surfaced as a popup banner (on demand) and
// a toolbar badge (daily). /api/extension/manifest is public and returns
// {version, latest_url, sha256}; the XPI is served from the web root (not /api).
function versionIsNewer(candidate, current) {
// Dotted numeric compare so 1.0.10 > 1.0.9 (a plain string compare wouldn't).
const a = String(candidate).split('.').map(n => parseInt(n, 10) || 0);
const b = String(current).split('.').map(n => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
}
return false;
}
async function checkForUpdateInfo() {
await ensureInitialized();
if (!api.isConfigured()) return { updateAvailable: false, configured: false };
let info;
try {
info = await api.getExtensionManifest();
} catch (e) {
return { updateAvailable: false, error: e.message };
}
const currentVersion = browser.runtime.getManifest().version;
const latestVersion = info && info.version ? info.version : null;
// latest_url is served from the web root, not the JSON API.
const base = api.webRoot();
return {
updateAvailable: !!latestVersion && versionIsNewer(latestVersion, currentVersion),
currentVersion,
latestVersion,
xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null,
};
}
async function refreshUpdateBadge() {
let r;
try { r = await checkForUpdateInfo(); } catch { return; }
try {
await browser.action.setBadgeText({ text: r.updateAvailable ? '↑' : '' });
if (r.updateAvailable) {
await browser.action.setBadgeBackgroundColor({ color: '#F4BA7A' });
await browser.action.setTitle({ title: `FabledCurator — update available (v${r.latestVersion})` });
} else {
await browser.action.setTitle({ title: 'FabledCurator' });
}
} catch { /* action API unavailable — non-fatal */ }
}
// Daily proactive check (needs the "alarms" permission). create() is idempotent
// by name, so re-running it on each event-page load is safe.
browser.alarms.create('fc-update-check', { periodInMinutes: 24 * 60, delayInMinutes: 1 });
browser.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'fc-update-check') refreshUpdateBadge();
});
browser.runtime.onStartup.addListener(() => refreshUpdateBadge());
browser.runtime.onInstalled.addListener(() => refreshUpdateBadge());
// ---- Discord token capture via webRequest ----
browser.webRequest.onBeforeSendHeaders.addListener(
@@ -148,6 +210,21 @@ browser.webRequest.onBeforeRedirect.addListener(
{ urls: ['https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback*'] },
);
// Extract → verify → upload one cookie-auth platform. Returns a structured
// outcome so the two callers (EXPORT_COOKIES single, EXPORT_ALL_COOKIES) shape
// their own response + skip semantics. Verifies the captured cookies are
// actually live BEFORE uploading, so a confirmed-stale session doesn't overwrite
// good FC-side credentials; platforms with no verify config (v.ok === null) fall
// through to upload.
async function exportPlatformCookies(key) {
const cookies = await extractCookiesForPlatform(key);
if (cookies.length === 0) return { status: 'empty' };
const v = await verifyCookiesForPlatform(key);
if (v.ok === false) return { status: 'stale', reason: v.reason, cookieCount: cookies.length };
await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
return { status: 'ok', cookieCount: cookies.length, verified: v.ok === true };
}
// ---- Message router ----
browser.runtime.onMessage.addListener(async (msg) => {
@@ -192,22 +269,14 @@ browser.runtime.onMessage.addListener(async (msg) => {
if (!platform) return { error: `Unknown platform: ${key}` };
try {
if (platform.authType === 'cookies') {
const cookies = await extractCookiesForPlatform(key);
if (cookies.length === 0) return { error: 'No cookies found — log in first.' };
// Verify the captured cookies are actually live BEFORE
// uploading. Skips upload on confirmed-stale sessions so we
// don't overwrite FC-side credentials with garbage. Platforms
// without a verify config (verify.ok === null) fall through
// to upload as before.
const v = await verifyCookiesForPlatform(key);
if (v.ok === false) {
const r = await exportPlatformCookies(key);
if (r.status === 'empty') return { error: 'No cookies found — log in first.' };
if (r.status === 'stale') {
return {
error: `Captured ${cookies.length} ${platform.name} cookies but they don't appear authenticated (${v.reason}). Log in again in this browser, then retry.`,
error: `Captured ${r.cookieCount} ${platform.name} cookies but they don't appear authenticated (${r.reason}). Log in again in this browser, then retry.`,
};
}
const data = toNetscapeFormat(cookies);
await api.uploadCredentials(key, 'cookies', data);
return { success: true, cookieCount: cookies.length, verified: v.ok === true };
return { success: true, cookieCount: r.cookieCount, verified: r.verified };
}
if (key === 'discord') {
if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
@@ -235,18 +304,10 @@ browser.runtime.onMessage.addListener(async (msg) => {
continue;
}
try {
const cookies = await extractCookiesForPlatform(key);
if (cookies.length === 0) {
results[key] = { skipped: true, reason: 'no cookies' };
continue;
}
const v = await verifyCookiesForPlatform(key);
if (v.ok === false) {
results[key] = { error: `verify failed: ${v.reason}` };
continue;
}
await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
results[key] = { success: true, cookieCount: cookies.length, verified: v.ok === true };
const r = await exportPlatformCookies(key);
if (r.status === 'empty') results[key] = { skipped: true, reason: 'no cookies' };
else if (r.status === 'stale') results[key] = { error: `verify failed: ${r.reason}` };
else results[key] = { success: true, cookieCount: r.cookieCount, verified: r.verified };
} catch (e) {
results[key] = { error: e.message };
}
@@ -283,11 +344,9 @@ browser.runtime.onMessage.addListener(async (msg) => {
}
case 'OPEN_ARTIST_PAGE': {
// apiUrl is configured with the /api suffix (see
// options/options.html placeholder); the SPA artist route is
// /artist/:slug, served from the same origin. Strip /api so the
// browser-level URL hits the Vue router, not the JSON API.
const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
// The SPA artist route (/artist/:slug) is served from the web root, not
// the JSON API — see api.webRoot().
const base = api.webRoot();
const slug = encodeURIComponent(msg.slug || '');
if (!base || !slug) return { error: 'apiUrl or slug missing' };
try {
@@ -298,6 +357,9 @@ browser.runtime.onMessage.addListener(async (msg) => {
}
}
case 'CHECK_UPDATE':
return await checkForUpdateInfo();
default:
return { error: `Unknown message type: ${msg.type}` };
}
+23 -1
View File
@@ -11,7 +11,10 @@ class FabledCuratorAPI {
async init() {
const cfg = await browser.storage.local.get(['apiUrl', 'apiKey']);
this.baseUrl = cfg.apiUrl || null;
// Normalize on READ, not just on save: configs stored before the options
// page started normalizing are missing the `/api` suffix, and this heals
// them without the operator having to reopen Settings.
this.baseUrl = normalizeApiUrl(cfg.apiUrl) || null;
this.apiKey = cfg.apiKey || null;
return this.isConfigured();
}
@@ -50,6 +53,13 @@ class FabledCuratorAPI {
} catch {
message = `HTTP ${response.status}: ${response.statusText}`;
}
// 404/405 from FC almost always means the request never reached the JSON
// API — it fell through to the SPA catch-all, which serves HTML on GET
// and rejects everything else. Say so, rather than making the operator
// decode "Method Not Allowed" on an endpoint that plainly allows POST.
if (response.status === 404 || response.status === 405) {
message += `${url} isn't the FC API. Check the FC URL in settings.`;
}
const err = new Error(message);
err.status = response.status;
throw err;
@@ -89,6 +99,18 @@ class FabledCuratorAPI {
const qs = new URLSearchParams({ url }).toString();
return this.request('GET', `/extension/probe?${qs}`);
}
// Latest published extension version on this instance — drives the in-app
// update prompt. Public endpoint (no key needed, but request() sends it
// harmlessly). Returns {version, xpi_url, latest_url, sha256}.
getExtensionManifest() {
return this.request('GET', '/extension/manifest');
}
// The web/SPA root: where the Vue router (artist pages) and the served XPI
// live, NOT the JSON API. Used by OPEN_ARTIST_PAGE + the self-update check.
webRoot() {
return webRootFromApiUrl(this.baseUrl);
}
// Connection test = the cheapest read with auth.
testConnection() {
+10 -1
View File
@@ -86,7 +86,16 @@ const PLATFORMS = {
* script to decide whether to show the floating "Add as source" button.
*/
const PLATFORM_ARTIST_PATTERNS = {
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c\/)[^/?#]+\/?$/i,
// Patreon serves the same creator under three URL shapes (see backend
// patreon_resolver._VANITY_RE): bare `patreon.com/Atole`, `c/` prefix, and
// `cw/` "creator workspace" — the last is the URL you land on once you're
// SUBSCRIBED, which is exactly when the button matters. Match all three, and
// drop the single-segment end-anchor so a creator's inner page
// (…/cw/Atole/posts, …/Atole/membership) also injects the button. Nav pages
// (home/search/…/posts permalink) stay excluded. Mirrors extension_service
// ._PLATFORM_PATTERNS — keep in sync (operator-flagged 2026-07-13: button
// vanished once subscribed because the old pattern only matched the bare root).
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?:cw\/|c\/)?(?!(?:home|search|messages|notifications|library|settings|posts)(?:[\/?#]|$))[^/?#]+/i,
subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i,
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i,
deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i,
+32
View File
@@ -0,0 +1,32 @@
/**
* Canonical FC endpoint derivation, shared by the background client and the
* options page so a URL entered either way behaves identically.
*
* FC serves two things on one origin: the JSON API under `/api`, and the Vue
* SPA from the root. `api.js` builds requests as `${baseUrl}/credentials`, so
* the stored base URL has to carry the `/api` suffix.
*/
/**
* Accept what an operator would naturally type — the instance root
* (`http://curator.example.com`) or the API root (`.../api`) — and return the
* API root either way.
*
* Worth normalizing rather than validating: a root-form URL doesn't fail
* loudly, it lands on the SPA catch-all, which answers `GET /credentials` with
* 200 HTML and rejects `POST /credentials` with 405. The operator sees a
* working Test Connection and a broken export.
*/
function normalizeApiUrl(raw) {
const trimmed = (raw || '').trim().replace(/\/+$/, '');
if (!trimmed) return '';
return /\/api$/i.test(trimmed) ? trimmed : `${trimmed}/api`;
}
/**
* The SPA root — where the Vue router (artist pages) and the served XPI live,
* NOT the JSON API. Accepts either input form, same as normalizeApiUrl.
*/
function webRootFromApiUrl(raw) {
return normalizeApiUrl(raw).replace(/\/api$/i, '');
}
+4 -3
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "FabledCurator",
"version": "1.0.7",
"version": "1.0.10",
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
"browser_specific_settings": {
@@ -22,7 +22,8 @@
"tabs",
"activeTab",
"webRequest",
"webRequestBlocking"
"webRequestBlocking",
"alarms"
],
"host_permissions": [
@@ -45,7 +46,7 @@
},
"background": {
"scripts": ["lib/platforms.js", "lib/cookies.js", "lib/api.js", "background/background.js"]
"scripts": ["lib/platforms.js", "lib/cookies.js", "lib/url.js", "lib/api.js", "background/background.js"]
},
"options_ui": {
+7 -3
View File
@@ -21,9 +21,12 @@
<body>
<h1>FabledCurator extension</h1>
<label for="api-url">FC base URL</label>
<input id="api-url" type="url" placeholder="http://curator.example.com/api" />
<div class="hint">Find this on FC → Settings → Maintenance → Browser extension.</div>
<label for="api-url">FC instance URL</label>
<input id="api-url" type="url" placeholder="http://curator.example.com" />
<div class="hint">
Your FabledCurator address — with or without the trailing <code>/api</code>; both work.
Find it on FC → Settings → Maintenance → Browser extension.
</div>
<label for="api-key">Extension API key</label>
<input id="api-key" type="password" placeholder="paste from FC Settings card" />
@@ -36,6 +39,7 @@
<div id="status" class="status" style="display:none;"></div>
<script src="../lib/url.js"></script>
<script src="options.js"></script>
</body>
</html>
+23 -5
View File
@@ -8,7 +8,7 @@ document.addEventListener('DOMContentLoaded', async () => {
});
async function save() {
const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, '');
const apiUrl = normalizeApiUrl(document.getElementById('api-url').value);
const apiKey = document.getElementById('api-key').value.trim();
if (!apiUrl || !apiKey) {
showStatus('Both fields are required.', 'err');
@@ -16,11 +16,14 @@ async function save() {
}
await browser.storage.local.set({ apiUrl, apiKey });
await browser.storage.local.remove(['lastConnectionTest', 'lastConnectionStatus']);
showStatus('Saved.', 'ok');
// Show what was actually stored — the operator may have typed the instance
// root and it was normalized to the API root.
document.getElementById('api-url').value = apiUrl;
showStatus(`Saved — using ${apiUrl}`, 'ok');
}
async function test() {
const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, '');
const apiUrl = normalizeApiUrl(document.getElementById('api-url').value);
const apiKey = document.getElementById('api-key').value.trim();
if (!apiUrl || !apiKey) {
showStatus('Fill both fields first.', 'err');
@@ -31,8 +34,23 @@ async function test() {
method: 'GET',
headers: { 'X-Extension-Key': apiKey },
});
if (r.ok) showStatus(`Connected — HTTP ${r.status}.`, 'ok');
else showStatus(`HTTP ${r.status}: ${r.statusText}`, 'err');
if (!r.ok) {
showStatus(`HTTP ${r.status}: ${r.statusText}`, 'err');
return;
}
// A 200 is NOT sufficient. If the URL resolves to the Vue SPA instead of
// the JSON API, the catch-all route returns 200 with an HTML document —
// which used to report "Connected" on a config that could not POST at all.
const contentType = r.headers.get('content-type') || '';
if (!contentType.includes('json')) {
showStatus(
`${apiUrl} answered with ${contentType || 'no content-type'}, not JSON `
+ '— that looks like the FC web UI rather than its API.',
'err',
);
return;
}
showStatus(`Connected to ${apiUrl} — HTTP ${r.status}.`, 'ok');
} catch (e) {
showStatus(`Cannot reach ${apiUrl}: ${e.message}`, 'err');
}
+8 -6
View File
@@ -1,15 +1,17 @@
{
"name": "fabledcurator-extension",
"version": "1.0.7",
"version": "1.0.10",
"private": true,
"description": "Firefox extension for FabledCurator",
"scripts": {
"lint": "web-ext lint --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore",
"start": "web-ext run --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --firefox=firefox",
"build": "web-ext build --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --overwrite-dest",
"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"
"lint": "web-ext lint --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore vitest.config.js \"test/**\"",
"start": "web-ext run --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore vitest.config.js \"test/**\" --firefox=firefox",
"build": "web-ext build --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore vitest.config.js \"test/**\" --overwrite-dest",
"sign": "web-ext sign --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore vitest.config.js \"test/**\" --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET",
"test:unit": "vitest run"
},
"devDependencies": {
"web-ext": "^8.0.0"
"vitest": "^4.0.0",
"web-ext": "^10.0.0"
}
}
+11
View File
@@ -72,6 +72,17 @@ body {
.btn.block { display: block; width: 100%; margin-top: 8px; }
.btn.link { background: none; color: var(--on-surface-variant); padding: 4px; }
.btn.link:hover { color: var(--accent); }
.btn.small { padding: 6px 12px; font-size: 13px; }
/* In-app update prompt (accent-tinted so it reads as an actionable notice). */
.update-banner {
display: flex; align-items: center; gap: 10px;
margin: 10px 10px 0; padding: 10px 12px;
background: rgba(244, 186, 122, 0.12);
border: 1px solid rgba(244, 186, 122, 0.4);
border-radius: 6px;
}
#update-text { flex: 1; font-size: 13px; }
.source-row .play {
background: none; border: none; color: var(--on-surface-variant);
+5
View File
@@ -20,6 +20,11 @@
</section>
<section id="main-content" class="main hidden">
<div id="update-banner" class="update-banner hidden">
<span id="update-text"></span>
<button id="update-btn" class="btn primary small">Update</button>
</div>
<nav class="tabs">
<button class="tab active" data-tab="platforms">Platforms</button>
<button class="tab" data-tab="sources">Sources</button>
+33 -12
View File
@@ -2,6 +2,15 @@ document.addEventListener('DOMContentLoaded', init);
const CONNECTION_TEST_INTERVAL = 2 * 60 * 1000;
// A centered muted note div — the loading / empty state shared by the platform
// and sources lists.
function mutedNote(text) {
const d = document.createElement('div');
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
d.textContent = text;
return d;
}
async function init() {
try {
const cfg = await browser.runtime.sendMessage({ type: 'GET_CONFIG' });
@@ -14,6 +23,7 @@ async function init() {
setupEventListeners();
showPlatformsLoading();
testConnectionIfNeeded();
checkForUpdate();
loadPlatformStatus().catch(e => showError(`Failed to load platforms: ${e.message}`));
} catch (e) {
showSetupRequired();
@@ -37,10 +47,7 @@ function showSetupRequired() {
function showPlatformsLoading() {
const c = document.getElementById('platforms-list');
c.textContent = '';
const d = document.createElement('div');
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
d.textContent = 'Loading platforms…';
c.appendChild(d);
c.appendChild(mutedNote('Loading platforms…'));
}
async function testConnectionIfNeeded() {
@@ -63,6 +70,26 @@ function updateConnectionDot(connected) {
d.title = connected ? 'Connected to FabledCurator' : 'Disconnected';
}
// Nudge to reinstall when the configured instance publishes a newer signed XPI
// (the extension is self-hosted, so there's no Firefox auto-update). Never
// blocks the popup — a failed check just leaves the banner hidden.
async function checkForUpdate() {
try {
const r = await browser.runtime.sendMessage({ type: 'CHECK_UPDATE' });
if (r && r.updateAvailable && r.xpiUrl) showUpdateBanner(r);
} catch { /* non-fatal */ }
}
function showUpdateBanner(r) {
document.getElementById('update-text').textContent =
`Update available — v${r.latestVersion} (installed v${r.currentVersion})`;
// Opening the signed XPI triggers Firefox's native install prompt.
document.getElementById('update-btn').addEventListener('click', () => {
browser.tabs.create({ url: r.xpiUrl });
});
document.getElementById('update-banner').classList.remove('hidden');
}
async function loadPlatformStatus() {
const status = await browser.runtime.sendMessage({ type: 'GET_PLATFORM_STATUS' });
const c = document.getElementById('platforms-list');
@@ -162,10 +189,7 @@ async function exportAllCookies() {
async function loadSources() {
const c = document.getElementById('sources-list');
c.textContent = '';
const d = document.createElement('div');
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
d.textContent = 'Loading sources…';
c.appendChild(d);
c.appendChild(mutedNote('Loading sources…'));
const r = await browser.runtime.sendMessage({ type: 'LIST_SOURCES' });
c.textContent = '';
if (r.error) {
@@ -176,10 +200,7 @@ async function loadSources() {
return;
}
if (!r.sources || r.sources.length === 0) {
const empty = document.createElement('div');
empty.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
empty.textContent = 'No sources yet.';
c.appendChild(empty);
c.appendChild(mutedNote('No sources yet.'));
return;
}
for (const src of r.sources) c.appendChild(createSourceRow(src));
+29
View File
@@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const LIB_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'lib')
/**
* Load an extension lib and hand back the globals it declares.
*
* The files under lib/ are CLASSIC scripts, not ES modules: manifest.json
* lists them in `background.scripts` and options.html pulls them in with a
* plain <script> tag, so they declare bare functions into a shared scope and
* export nothing. Rather than bolt a `module.exports` shim onto production
* code that would never run in the browser, evaluate the real file the same
* way the browser does — as a script body — and pick the declarations back out.
*
* This means the specs exercise the exact bytes that get packaged into the
* XPI. Only usable for libs that touch no browser APIs at load time
* (url.js, platforms.js); cookies.js and api.js reference `browser.*` and
* would need stubbing, which is why they aren't loaded this way.
*
* @param {string} filename e.g. 'url.js'
* @param {string[]} names declarations to return, e.g. ['normalizeApiUrl']
*/
export function loadLib(filename, names) {
const source = readFileSync(path.join(LIB_DIR, filename), 'utf8')
const factory = new Function(`${source}\nreturn { ${names.join(', ')} }`)
return factory()
}
+127
View File
@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest'
import { loadLib } from './helpers/loadLib.js'
const { getPlatformFromUrl, isArtistPage, PLATFORMS, PLATFORM_ARTIST_PATTERNS } = loadLib(
'platforms.js',
['getPlatformFromUrl', 'isArtistPage', 'PLATFORMS', 'PLATFORM_ARTIST_PATTERNS']
)
describe('getPlatformFromUrl', () => {
it('identifies each platform from a domain URL', () => {
expect(getPlatformFromUrl('https://www.patreon.com/Atole')).toBe('patreon')
expect(getPlatformFromUrl('https://subscribestar.adult/someone')).toBe('subscribestar')
expect(getPlatformFromUrl('https://www.hentai-foundry.com/user/someone')).toBe('hentaifoundry')
expect(getPlatformFromUrl('https://discord.com/channels/@me')).toBe('discord')
expect(getPlatformFromUrl('https://www.pixiv.net/en/users/123')).toBe('pixiv')
expect(getPlatformFromUrl('https://www.deviantart.com/someone')).toBe('deviantart')
})
it('accepts http as well as https, with or without www', () => {
expect(getPlatformFromUrl('http://patreon.com/Atole')).toBe('patreon')
expect(getPlatformFromUrl('https://www.patreon.com/Atole')).toBe('patreon')
})
it('returns null for unrelated hosts', () => {
expect(getPlatformFromUrl('https://example.com/patreon.com')).toBe(null)
expect(getPlatformFromUrl('https://not-patreon.com/Atole')).toBe(null)
expect(getPlatformFromUrl('')).toBe(null)
})
})
describe('isArtistPage', () => {
// Regression cases from issue #1485: the Add-to-FC button vanished once the
// operator SUBSCRIBED to a creator, because Patreon serves subscribed users
// the /cw/ ("creator workspace") URL and the pattern only matched the bare
// root. All three creator URL shapes must match, plus inner pages — the
// button matters most exactly when you're subscribed.
it('matches all three Patreon creator URL shapes', () => {
expect(isArtistPage('https://www.patreon.com/Atole', 'patreon')).toBe(true)
expect(isArtistPage('https://www.patreon.com/c/Atole', 'patreon')).toBe(true)
expect(isArtistPage('https://www.patreon.com/cw/Atole', 'patreon')).toBe(true)
})
it('matches Patreon creator inner pages', () => {
expect(isArtistPage('https://www.patreon.com/cw/Atole/posts', 'patreon')).toBe(true)
expect(isArtistPage('https://www.patreon.com/Atole/membership', 'patreon')).toBe(true)
})
it('excludes Patreon navigation pages that are not creators', () => {
for (const nav of ['home', 'search', 'messages', 'notifications', 'library', 'settings']) {
expect(isArtistPage(`https://www.patreon.com/${nav}`, 'patreon')).toBe(false)
expect(isArtistPage(`https://www.patreon.com/${nav}/anything`, 'patreon')).toBe(false)
}
})
it('matches SubscribeStar creator roots on both TLDs but not feed pages', () => {
expect(isArtistPage('https://subscribestar.adult/someone', 'subscribestar')).toBe(true)
expect(isArtistPage('https://subscribestar.com/someone', 'subscribestar')).toBe(true)
expect(isArtistPage('https://subscribestar.adult/feed', 'subscribestar')).toBe(false)
expect(isArtistPage('https://subscribestar.adult/messages', 'subscribestar')).toBe(false)
})
it('matches Hentai Foundry user pages only', () => {
expect(isArtistPage('https://www.hentai-foundry.com/user/someone', 'hentaifoundry')).toBe(true)
expect(isArtistPage('https://www.hentai-foundry.com/pictures/popular', 'hentaifoundry')).toBe(
false
)
})
it('matches Pixiv numeric user pages, with or without the /en/ prefix', () => {
expect(isArtistPage('https://www.pixiv.net/users/12345', 'pixiv')).toBe(true)
expect(isArtistPage('https://www.pixiv.net/en/users/12345', 'pixiv')).toBe(true)
expect(isArtistPage('https://www.pixiv.net/en/artworks/999', 'pixiv')).toBe(false)
})
it('excludes DeviantArt navigation roots', () => {
expect(isArtistPage('https://www.deviantart.com/someone', 'deviantart')).toBe(true)
expect(isArtistPage('https://www.deviantart.com/home', 'deviantart')).toBe(false)
expect(isArtistPage('https://www.deviantart.com/watch', 'deviantart')).toBe(false)
})
it('returns false for a platform with no artist pattern (discord)', () => {
expect(isArtistPage('https://discord.com/channels/@me', 'discord')).toBe(false)
})
it('returns false for an unknown platform key', () => {
expect(isArtistPage('https://www.patreon.com/Atole', 'nope')).toBe(false)
})
})
describe('platform table integrity', () => {
it('gives every artist pattern a corresponding platform entry', () => {
// A pattern keyed to a platform that no longer exists is dead code that
// silently never fires; the reverse (a platform with no pattern) is the
// legitimate discord case, so only this direction is an error.
for (const key of Object.keys(PLATFORM_ARTIST_PATTERNS)) {
expect(Object.keys(PLATFORMS)).toContain(key)
}
})
it('gives every platform the fields the popup renders', () => {
for (const [key, platform] of Object.entries(PLATFORMS)) {
expect(platform.name, `${key}.name`).toBeTruthy()
expect(platform.color, `${key}.color`).toMatch(/^#[0-9A-Fa-f]{6}$/)
expect(['cookies', 'token'], `${key}.authType`).toContain(platform.authType)
expect(platform.urlPattern, `${key}.urlPattern`).toBeInstanceOf(RegExp)
expect(Array.isArray(platform.domains), `${key}.domains`).toBe(true)
expect(platform.domains.length, `${key}.domains`).toBeGreaterThan(0)
}
})
it('keeps every artist URL matched by its own platform pattern too', () => {
// isArtistPage is only ever consulted after getPlatformFromUrl resolves a
// key, so an artist pattern matching a URL its platform's urlPattern
// rejects would be unreachable.
const samples = {
patreon: 'https://www.patreon.com/cw/Atole',
subscribestar: 'https://subscribestar.adult/someone',
hentaifoundry: 'https://www.hentai-foundry.com/user/someone',
deviantart: 'https://www.deviantart.com/someone',
pixiv: 'https://www.pixiv.net/en/users/12345'
}
for (const [key, url] of Object.entries(samples)) {
expect(isArtistPage(url, key), `${key} artist pattern`).toBe(true)
expect(getPlatformFromUrl(url), `${key} urlPattern`).toBe(key)
}
})
})
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest'
import { loadLib } from './helpers/loadLib.js'
const { normalizeApiUrl, webRootFromApiUrl } = loadLib('url.js', [
'normalizeApiUrl',
'webRootFromApiUrl'
])
describe('normalizeApiUrl', () => {
// The bug this exists for (issue #2393): the instance root was accepted and
// stored verbatim, so every request went to /credentials instead of
// /api/credentials. That path is a Vue router route, so the SPA catch-all
// answered GET with 200 HTML and rejected POST with 405 — which read as a
// backend bug rather than a URL one.
it('appends /api to an instance root', () => {
expect(normalizeApiUrl('http://curator.traefik.internal')).toBe(
'http://curator.traefik.internal/api'
)
})
it('leaves an API root alone rather than doubling the suffix', () => {
expect(normalizeApiUrl('http://curator.traefik.internal/api')).toBe(
'http://curator.traefik.internal/api'
)
})
it('is idempotent', () => {
const once = normalizeApiUrl('http://curator.example.com')
expect(normalizeApiUrl(once)).toBe(once)
})
it('strips trailing slashes before deciding', () => {
expect(normalizeApiUrl('http://curator.example.com/')).toBe('http://curator.example.com/api')
expect(normalizeApiUrl('http://curator.example.com///')).toBe('http://curator.example.com/api')
expect(normalizeApiUrl('http://curator.example.com/api/')).toBe('http://curator.example.com/api')
})
it('trims surrounding whitespace (paste artifacts)', () => {
expect(normalizeApiUrl(' http://curator.example.com ')).toBe(
'http://curator.example.com/api'
)
})
it('matches the /api suffix case-insensitively', () => {
expect(normalizeApiUrl('http://curator.example.com/API')).toBe('http://curator.example.com/API')
})
it('returns empty string for empty/nullish input, never a bare "/api"', () => {
// isConfigured() gates on truthiness, so a bogus '/api' here would read as
// "configured" and produce a request against the options page's own origin.
expect(normalizeApiUrl('')).toBe('')
expect(normalizeApiUrl(' ')).toBe('')
expect(normalizeApiUrl(null)).toBe('')
expect(normalizeApiUrl(undefined)).toBe('')
})
it('does not treat a path merely containing "api" as the suffix', () => {
expect(normalizeApiUrl('http://curator.example.com/apiary')).toBe(
'http://curator.example.com/apiary/api'
)
})
it('preserves a subpath deployment', () => {
expect(normalizeApiUrl('http://host.internal/curator')).toBe('http://host.internal/curator/api')
})
})
describe('webRootFromApiUrl', () => {
// The SPA root, where the Vue router and the served XPI live. Used by
// OPEN_ARTIST_PAGE and the self-update check — NOT the JSON API.
it('strips the /api suffix', () => {
expect(webRootFromApiUrl('http://curator.example.com/api')).toBe('http://curator.example.com')
})
it('accepts an instance root unchanged', () => {
expect(webRootFromApiUrl('http://curator.example.com')).toBe('http://curator.example.com')
})
it('agrees with normalizeApiUrl in both directions', () => {
for (const input of ['http://curator.example.com', 'http://curator.example.com/api']) {
expect(normalizeApiUrl(webRootFromApiUrl(input))).toBe(normalizeApiUrl(input))
}
})
it('preserves a subpath deployment', () => {
expect(webRootFromApiUrl('http://host.internal/curator/api')).toBe('http://host.internal/curator')
})
it('returns empty string for empty/nullish input', () => {
expect(webRootFromApiUrl('')).toBe('')
expect(webRootFromApiUrl(null)).toBe('')
})
})
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const read = (name) => JSON.parse(readFileSync(path.join(EXT_DIR, name), 'utf8'))
describe('extension version consistency', () => {
// Duplicates check (1) of ci.yml's extension-version job, deliberately.
// That job is the gate that can't be bypassed; this spec is the one that
// fails in a second on the developer's own CI lane with a readable diff.
// The two version strings feed different systems and nothing else reconciles
// them:
// manifest.json -> what `web-ext sign` signs, so what Firefox installs
// (package.json is in --ignore-files, not in the XPI)
// package.json -> build.yml's AMO cache key, the ext-<version> release
// tag, the XPI filename, and therefore the version
// /api/extension/manifest reports to the update prompt
it('keeps manifest.json and package.json in lockstep', () => {
const manifest = read('manifest.json')
const pkg = read('package.json')
expect(manifest.version).toBe(pkg.version)
})
it('uses a plain dotted numeric version AMO will accept', () => {
// AMO rejects exotic version strings, and build.yml embeds this value in a
// release tag and a filename — so anything needing escaping breaks the
// publish path rather than the extension.
expect(read('package.json').version).toMatch(/^\d+(\.\d+)*$/)
})
it('declares manifest v3', () => {
expect(read('manifest.json').manifest_version).toBe(3)
})
it('never lets the CI guard ignore a file that actually ships', () => {
// ci.yml's extension-version job skips its bump check for paths it deems
// non-shipping. If it excludes something web-ext DOES package, a real
// change to shipped code passes the guard unnoticed — precisely the
// silent-stale-ship the guard exists to stop. The reverse drift (guard
// stricter than web-ext) only costs a needless bump, so it isn't asserted.
const ci = readFileSync(path.join(EXT_DIR, '..', '.forgejo', 'workflows', 'ci.yml'), 'utf8')
const lint = read('package.json').scripts.lint
const after = lint.split('--ignore-files')[1] ?? ''
const ignored = new Set(
after
.split(/\s+/)
.filter((tok) => tok && !tok.startsWith('--'))
.map((tok) => tok.replace(/^["']|["']$/g, ''))
)
expect(ignored.size, 'parsed --ignore-files from the lint script').toBeGreaterThan(0)
const guarded = [...ci.matchAll(/:\(exclude\)extension\/(\S+?)'/g)].map((m) => m[1])
expect(guarded.length, 'parsed :(exclude) entries from ci.yml').toBeGreaterThan(0)
for (const entry of guarded) {
expect(ignored, `ci.yml excludes "${entry}" but web-ext packages it`).toContain(entry)
}
})
it('lists every background script that exists, in dependency order', () => {
// url.js must load BEFORE api.js: api.js calls normalizeApiUrl at
// init()-time, and these are classic scripts sharing one scope, so a
// reordering here is a runtime ReferenceError with no build-time signal.
const scripts = read('manifest.json').background.scripts
for (const rel of scripts) {
expect(() => readFileSync(path.join(EXT_DIR, rel)), `missing ${rel}`).not.toThrow()
}
expect(scripts.indexOf('lib/url.js')).toBeLessThan(scripts.indexOf('lib/api.js'))
})
})
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config'
// Mirrors frontend/vitest.config.js, minus the Vue plugin — the extension has
// no SFCs and mounts nothing. Pure-logic specs only, so `node` is enough; the
// libs under test are deliberately the ones with no browser-API surface (see
// test/helpers/loadLib.js).
export default defineConfig({
test: {
environment: 'node',
include: ['test/**/*.spec.js'],
passWithNoTests: true
}
})
+6 -6
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"engines": {
"node": ">=22"
"node": ">=24"
},
"scripts": {
"dev": "vite",
@@ -13,16 +13,16 @@
"test:unit": "vitest run"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.0",
"vuetify": "^3.5.0",
"vue": "^3.5.0",
"vue-router": "^5.0.0",
"pinia": "^3.0.0",
"vuetify": "^4.0.0",
"@mdi/font": "^7.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"vite": "^8.0.0",
"vite-plugin-vuetify": "^2.0.0",
"vite-plugin-vuetify": "^2.1.0",
"sass": "^1.71.0",
"vitest": "^4.0.0",
"@vue/test-utils": "^2.4.0",
+6 -6
View File
@@ -18,11 +18,11 @@ const route = useRoute()
<style scoped>
.fc-content {
min-height: 100vh;
/* Push initial viewport content below the sticky TopNav. Without
this, some views' first rows / form fields / table headers can
end up obscured by the navbar (depending on parent overflow
context interacting with position: sticky). Scrolled-down content
still slides under the nav — the gradient-fade design is intact. */
padding-top: 64px;
/* NO padding-top: the TopNav is position:sticky, so it already reserves its
own space in the v-app flex column — content flows directly below it. The
old 64px padding-top was a leftover from a FIXED navbar and double-counted
that space, leaving a large empty band at the top of EVERY view (and pushing
the full-height calc(100vh - 64px) views down so they overflowed). Removed
2026-07-13. Scrolled content still slides under the sticky nav as before. */
}
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<v-snackbar
v-model="show" :color="color" location="bottom right" timeout="4000"
multi-line elevation="4"
min-height="68" elevation="4"
>
{{ message }}
<template #actions>
+52 -7
View File
@@ -1,5 +1,5 @@
<template>
<header class="fc-topnav">
<header ref="navEl" class="fc-topnav" :class="{ 'fc-topnav--chrome': hasStickyChrome }">
<div class="fc-nav-left">
<RouterLink :to="FRONT_DOOR" class="fc-brand" aria-label="FabledCurator home">
<img src="/favicon.svg" alt="" class="fc-brand__glyph" width="22" height="22" />
@@ -64,13 +64,39 @@
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import router, { FRONT_DOOR } from '../router.js'
import { useSystemStore } from '../stores/system.js'
import PipelineStatusChip from './PipelineStatusChip.vue'
const system = useSystemStore()
onMounted(() => system.refreshHealth())
// Publish the nav's REAL height as --fc-nav-h so full-height workspaces
// (Explore/Subscriptions) and sticky sub-headers pin to it exactly instead of a
// hardcoded 64px that Vuetify 4's MD3 sizing broke — the Explore breadcrumb was
// tucking under a taller nav (#1481). ResizeObserver keeps it live as the nav
// reflows (per-view teleported actions, mobile breakpoint, chip state changes).
const navEl = ref(null)
let navRO = null
onMounted(() => {
system.refreshHealth()
if (navEl.value && 'ResizeObserver' in window) {
navRO = new ResizeObserver(() => {
const h = navEl.value?.offsetHeight
if (h) document.documentElement.style.setProperty('--fc-nav-h', `${h}px`)
})
navRO.observe(navEl.value)
}
})
onBeforeUnmount(() => { navRO?.disconnect() })
// Views that pin a sticky sub-header (filter bar / tabs) directly under the nav
// declare `meta.stickyChrome`. On those, the nav doesn't fade to transparent at
// its bottom — it hands off at the shared seam alpha so the sub-header can
// continue the SAME fade (see .fc-chrome-continues in app.css). One gradient.
const route = useRoute()
const hasStickyChrome = computed(() => !!route.meta?.stickyChrome)
// Every route with a meta.title is a nav entry. Order by meta.navOrder —
// router.getRoutes() does NOT guarantee declaration order, so explicit numbers
@@ -119,16 +145,35 @@ const health = computed(() => {
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
/* Obsidian (#14171A = 20,23,26) gradient fade — content scrolls under it. */
/* Obsidian (#14171A) fade — content scrolls under it. Holds high (0.92 →
0.84) through the top half, then eases to transparent over the bottom
quarter so it tails off softly instead of a straight line to a hard edge
(operator 2026-07-13). Shared --fc-chrome-rgb keeps it in sync with the
sub-header continuation. */
background: linear-gradient(
to bottom,
rgba(20, 23, 26, 0.92) 0%,
rgba(20, 23, 26, 0.65) 60%,
rgba(20, 23, 26, 0) 100%
rgba(var(--fc-chrome-rgb), 0.92) 0%,
rgba(var(--fc-chrome-rgb), 0.84) 50%,
rgba(var(--fc-chrome-rgb), 0.55) 75%,
rgba(var(--fc-chrome-rgb), 0) 100%
);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
/* On a view with a sticky sub-header pinned beneath (meta.stickyChrome), the nav
stops fading at the shared seam alpha instead of going fully transparent — the
sub-header (.fc-chrome-continues) picks the fade up from there, so the two read
as one continuous gradient. Compound selector out-specifies .fc-topnav so it
wins regardless of Vite's production CSS ordering. --fc-chrome-* come from the
global :root in app.css (custom props inherit into scoped styles). */
.fc-topnav.fc-topnav--chrome {
background: linear-gradient(
to bottom,
rgba(var(--fc-chrome-rgb), 0.92) 0%,
rgba(var(--fc-chrome-rgb), 0.84) 60%,
rgba(var(--fc-chrome-rgb), var(--fc-chrome-seam)) 100%
);
}
.fc-brand {
display: flex;
@@ -10,7 +10,7 @@
filter, applied retroactively to the existing library.
</p>
<v-row dense>
<v-row density="compact">
<v-col cols="6">
<v-text-field
v-model.number="minW" label="Min width (px)" type="number"
@@ -13,7 +13,7 @@
cadence as the transparency audit.
</p>
<v-row dense>
<v-row density="compact">
<v-col cols="6">
<v-text-field
v-model.number="threshold" label="Threshold (01)"
@@ -0,0 +1,56 @@
<!--
Canonical settings number field (DRY pass #161): a compact numeric v-text-field
with a built-in clamp to [min,max] on commit. Hand-rolled identically across the
ML settings cards (HeadsCard x6, CropProposersCard, VideoEmbeddingCard).
The clamp is the point: the cards previously sent Number(raw) straight to the
API, so an out-of-range value bounced off the API's 400 validator (only
TranslationCard clamped). This is now the single home for that clamp.
Binds `modelValue` (v-model) and emits `change` on blur/enter AFTER clamping, so
the parent's save reads the already-clamped value same as the prior
`v-model.number` + `@change=save` pattern.
-->
<template>
<v-text-field
:model-value="modelValue"
:label="label"
type="number"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
:density="density" hide-details
:style="{ maxWidth }"
@update:model-value="v => emit('update:modelValue', v)"
@change="onCommit"
/>
</template>
<script setup>
const props = defineProps({
modelValue: { type: [Number, String], default: null },
label: { type: String, default: '' },
min: { type: [Number, String], default: null },
max: { type: [Number, String], default: null },
step: { type: [Number, String], default: 1 },
maxWidth: { type: String, default: '200px' },
density: { type: String, default: 'compact' },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'change'])
function onCommit() {
// On blur/enter: coerce to a number and clamp to [min,max] so an out-of-range
// value never reaches the API. props.modelValue reflects the latest keystroke
// (kept in sync by the passthrough above); re-emit the clamped number, then let
// the parent persist.
let n = Number(props.modelValue)
if (!Number.isNaN(n)) {
if (props.min !== null && props.min !== '') n = Math.max(Number(props.min), n)
if (props.max !== null && props.max !== '') n = Math.min(Number(props.max), n)
if (n !== Number(props.modelValue)) emit('update:modelValue', n)
}
emit('change')
}
</script>
@@ -0,0 +1,42 @@
<!--
Canonical settings toggle row (DRY pass #161): an accent icon + an uppercase
.fc-section-h label + a right-aligned switch. Hand-rolled identically in the
ML settings cards (HeadsCard x3, CropProposersCard, MLBackfillCard).
Two-way binds `modelValue` (so the parent switch state stays optimistic) AND
emits `change` with the new boolean, so the parent can persist + revert on
failure matching the prior `v-model` + `@update:model-value=handler` pattern.
-->
<template>
<div class="d-flex align-center mb-1" style="gap: 10px;">
<v-icon v-if="icon" size="18" :color="iconColor">{{ icon }}</v-icon>
<span class="fc-section-h">{{ label }}</span>
<v-switch
:model-value="modelValue"
:loading="loading"
:disabled="disabled"
hide-details density="compact" color="success" class="ml-auto"
@update:model-value="onSwitch"
/>
</div>
</template>
<script setup>
defineProps({
modelValue: { type: Boolean, default: false },
label: { type: String, default: '' },
icon: { type: String, default: '' },
// Icon tint. Default accent; pass null for the theme default (e.g. when a row
// is off). null (not undefined) so the default doesn't override it.
iconColor: { type: String, default: 'accent' },
loading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'change'])
function onSwitch(v) {
const b = !!v
emit('update:modelValue', b)
emit('change', b)
}
</script>
+3 -3
View File
@@ -14,10 +14,10 @@
@update:search="onSearch"
@update:model-value="onPick"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps" :title="item.raw.name">
<template #item="{ props: itemProps, internalItem }">
<v-list-item v-bind="itemProps" :title="internalItem.raw.name">
<template #subtitle>
{{ item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind }}
{{ internalItem.raw.fandom_name ? `character · ${internalItem.raw.fandom_name}` : internalItem.raw.kind }}
</template>
</v-list-item>
</template>
@@ -1,5 +1,5 @@
<template>
<div class="fc-filterbar-wrap">
<div class="fc-filterbar-wrap fc-chrome-continues">
<div class="fc-filterbar">
<v-autocomplete
v-model="selected"
@@ -13,14 +13,14 @@
@update:search="onSearch"
@update:model-value="onPick"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps" :title="item.raw.name">
<template #item="{ props: itemProps, internalItem }">
<v-list-item v-bind="itemProps" :title="internalItem.raw.name">
<template #prepend>
<v-icon size="small">{{ iconFor(item.raw) }}</v-icon>
<v-icon size="small">{{ iconFor(internalItem.raw) }}</v-icon>
</template>
<template #subtitle>
{{ item.raw.kind === 'artist' ? 'artist'
: (item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind) }}
{{ internalItem.raw.kind === 'artist' ? 'artist'
: (internalItem.raw.fandom_name ? `character · ${internalItem.raw.fandom_name}` : internalItem.raw.kind) }}
</template>
</v-list-item>
</template>
@@ -306,27 +306,17 @@ function pushFilter(mutate) {
frosted block pinned directly under the 64px TopNav and continuous with it. */
.fc-filterbar-wrap {
position: sticky;
top: 64px;
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
z-index: 5;
/* Attach to the TopNav: cancel the v-container's top padding (pt-2 = 8px)
so the bar sits flush at 64px even at scroll 0 — without this it detaches
and a gap shows through when scrolled to the top. */
margin-top: -8px;
margin-bottom: 12px;
/* EXACT same gradiated obsidian (#14171A = 20,23,26) frost as the TopNav so
the two read as one continuous piece of chrome — images scroll visibly
under both. The nav's gradient fades to transparent at ITS bottom; this
bar re-darkens at its top, so a faint seam (the page/image showing through
the nav's transparent edge) separates them when scrolled to the very top,
while under-scroll they frost as one. */
background: linear-gradient(
to bottom,
rgba(20, 23, 26, 0.92) 0%,
rgba(20, 23, 26, 0.65) 60%,
rgba(20, 23, 26, 0) 100%
);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
/* The frost itself (obsidian fade + blur) is the shared .fc-chrome-continues
primitive: it CONTINUES the TopNav's fade from the seam alpha to transparent
rather than re-darkening, so the nav + bar read as one gradient (operator
2026-07-13). This block only owns the sticky positioning now. */
}
.fc-filterbar {
display: flex;
@@ -341,6 +331,20 @@ function pushFilter(mutate) {
.fc-filterbar-wrap :deep(.v-btn-group) {
background-color: rgba(20, 23, 26, 0.72);
}
/* Media toggle (All / Images / Videos) as ONE cohesive segmented control.
FC's global VBtn { rounded: 'pill' } default made Vuetify 4 pill-round each
SEGMENT individually, so the rounded ends collided at the joins — the shapes
landed awkwardly on the button edges (operator 2026-07-13). Square the inner
segments (over the pill utility's !important) and clip the group to a single
8px outline (matches the chips/tiles rounding elsewhere in the app). Radius
only — no height change, so the bar height and nav offset are untouched. */
.fc-filterbar-wrap :deep(.v-btn-toggle) {
border-radius: 8px;
overflow: hidden;
}
.fc-filterbar-wrap :deep(.v-btn-toggle .v-btn) {
border-radius: 0 !important;
}
.fc-filterbar__search { max-width: 320px; min-width: 200px; }
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
/* The tag chips' bodies toggle include/exclude — signal they're clickable. */
@@ -1,14 +1,15 @@
<template>
<!-- Auto-hidden chrome that ALSO looked like real content surfaced PROACTIVELY
atop the gallery whenever there's something to review (NOT gated on the
Show-hidden toggle, so misfires can't go unnoticed), most-concerning first,
with keep / un-hide (#141). Renders nothing when there's nothing to review. -->
<section v-if="items.length" class="fc-review" aria-label="Hidden images to review">
<!-- System-tag auto-applies (chrome hides / process WIP tags) that ALSO looked
like real content surfaced PROACTIVELY atop the gallery whenever there's
something to review (NOT gated on the Show-hidden toggle, so misfires can't
go unnoticed), most-concerning first, with keep / remove (#141, #1464).
Renders nothing when there's nothing to review. -->
<section v-if="items.length" class="fc-review" aria-label="Auto-tagged images to review">
<div class="fc-review__head">
<v-icon size="18" color="warning">mdi-alert-outline</v-icon>
<span class="fc-review__title">
{{ items.length }} auto-hidden {{ items.length === 1 ? 'image' : 'images' }}
may be real content — review before they stay hidden
{{ items.length }} auto-tagged {{ items.length === 1 ? 'image' : 'images' }}
may be real content — review
</span>
</div>
<div class="fc-review__cards">
@@ -26,16 +27,16 @@
>
also looks like <strong>{{ it.conflict_name || 'content' }}</strong>
</div>
<div class="fc-review-card__tag">hidden as {{ it.tag_name }}</div>
<div class="fc-review-card__tag">{{ tagLine(it) }}</div>
<div class="fc-review-card__acts">
<button
type="button" class="fc-review-btn fc-review-btn--keep"
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'keep')"
>Keep hidden</button>
>{{ keepLabel(it) }}</button>
<button
type="button" class="fc-review-btn fc-review-btn--unhide"
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'unhide')"
>Un-hide</button>
>{{ removeLabel(it) }}</button>
</div>
</div>
</div>
@@ -54,6 +55,11 @@ const items = ref([])
const busy = ref([])
function keyOf(it) { return `${it.image_id}:${it.tag_id}` }
// Chrome flags hide the image (keep-hidden / un-hide); process flags leave it
// visible and just tagged (keep-tag / remove-tag). Same endpoints, different words.
function tagLine(it) { return (it.mode === 'process' ? 'auto-tagged ' : 'hidden as ') + it.tag_name }
function keepLabel(it) { return it.mode === 'process' ? 'Keep tag' : 'Keep hidden' }
function removeLabel(it) { return it.mode === 'process' ? 'Remove tag' : 'Un-hide' }
async function load() {
// Fetched unconditionally on mount — the strip prompts for pending misfires
@@ -71,7 +77,8 @@ async function resolve(it, action) {
await api.post(`/api/gallery/hidden-review/${it.image_id}/${it.tag_id}/${action}`)
items.value = items.value.filter((x) => keyOf(x) !== k)
if (action === 'unhide') {
toast({ text: `Un-hidden — “${it.tag_name}” removed; it'll train the head`, type: 'success' })
const verb = it.mode === 'process' ? 'Removed' : 'Un-hidden'
toast({ text: `${verb} — “${it.tag_name}” removed; it'll train the head`, type: 'success' })
}
} catch (e) {
toast({
@@ -15,28 +15,23 @@
</p>
<div v-for="p in proposers" :key="p.key" class="fc-proposer">
<div class="d-flex align-center mb-1" style="gap: 10px;">
<v-icon size="18" :color="p.on ? 'accent' : undefined">{{ p.icon }}</v-icon>
<span class="fc-section-h">{{ p.label }}</span>
<v-switch
v-model="p.on" :loading="busy" hide-details density="compact"
color="success" class="ml-auto"
@update:model-value="v => saveToggle(p, v)"
/>
</div>
<SettingToggleRow
v-model="p.on" :loading="busy" :icon="p.icon"
:icon-color="p.on ? 'accent' : null" :label="p.label"
@change="v => saveToggle(p, v)"
/>
<p class="fc-muted text-body-2 mb-2">{{ p.help }}</p>
<div class="d-flex flex-wrap mb-4" style="gap: 12px;">
<v-text-field
v-model="p.weights" label="Weights" density="compact" hide-details
style="min-width: 300px; flex: 1;" :disabled="busy || !p.on"
placeholder="name | URL | hf_repo::file"
@change="save({ [`detector_${p.key}_weights`]: p.weights })"
@change="saveField({ [`detector_${p.key}_weights`]: p.weights })"
/>
<v-text-field
v-model.number="p.conf" label="Confidence" type="number"
min="0" max="1" step="0.05" density="compact" hide-details
style="max-width: 140px;" :disabled="busy || !p.on"
@change="save({ [`detector_${p.key}_conf`]: Number(p.conf) })"
<SettingNumberField
v-model="p.conf" label="Confidence" :min="0" :max="1" :step="0.05"
max-width="140px" :disabled="busy || !p.on"
@change="saveField({ [`detector_${p.key}_conf`]: Number(p.conf) })"
/>
</div>
</div>
@@ -48,12 +43,12 @@
storage. Dedupe IoU drops near-duplicate crops before embedding.
</p>
<div class="d-flex flex-wrap" style="gap: 12px;">
<v-text-field
<SettingNumberField
v-for="c in caps" :key="c.key"
v-model.number="c.val" :label="c.label" type="number"
:min="c.min" :max="c.max" :step="c.step || 1" density="compact"
hide-details style="max-width: 165px;" :disabled="busy"
@change="save({ [c.key]: Number(c.val) })"
v-model="c.val" :label="c.label"
:min="c.min" :max="c.max" :step="c.step || 1"
max-width="165px" :disabled="busy"
@change="saveField({ [c.key]: Number(c.val) })"
/>
</div>
</div>
@@ -61,14 +56,16 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { onMounted, ref } from 'vue'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import SettingNumberField from '../common/SettingNumberField.vue'
import SettingToggleRow from '../common/SettingToggleRow.vue'
import { useSettingSave } from '../../composables/useSettingSave.js'
import { useMLStore } from '../../stores/ml.js'
const mlSettings = useMLStore()
const busy = ref(false)
const { busy, save } = useSettingSave(mlSettings.patchSettings)
const proposers = ref([])
const caps = ref([])
@@ -111,31 +108,20 @@ onMounted(async () => {
caps.value = CAP_DEFS.map(c => ({ ...c, val: s[c.key] ?? 0 }))
})
async function save(patch, revert) {
busy.value = true
try {
await mlSettings.patchSettings(patch)
toast({ text: 'Saved', type: 'success' })
} catch (e) {
if (revert) revert()
toast({ text: `Could not save: ${e.message}`, type: 'error' })
} finally {
busy.value = false
}
// Field @change → persist with a "Saved" confirmation. SettingNumberField has
// already clamped numeric values to their [min,max] before this fires.
function saveField(patch) {
save(patch, { successMessage: 'Saved' })
}
function saveToggle (p, v) {
async function saveToggle(p, v) {
// Revert the switch on failure so it never lies about the persisted state.
save({ [`detector_${p.key}_enabled`]: !!v }, () => { p.on = !v })
const ok = await save({ [`detector_${p.key}_enabled`]: !!v }, { successMessage: 'Saved' })
if (!ok) p.on = !v
}
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-section-h {
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
}
.fc-proposer {
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 14px;
}
@@ -112,7 +112,6 @@ async function onCommit() {
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-code {
background: rgb(var(--v-theme-surface-light));
border-radius: 4px; padding: 2px 8px;
@@ -42,7 +42,7 @@
</tr>
</tbody>
</v-table>
<p v-else class="text-caption mt-3" style="opacity: 0.6;">
<p v-else class="text-caption mt-3 fc-muted">
No table statistics yet.
</p>
</MaintenanceTile>
@@ -72,6 +72,5 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) })
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-bad { color: rgb(var(--v-theme-error)); }
</style>
@@ -95,7 +95,6 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) })
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-cells { display: flex; gap: 28px; }
.fc-cell__n {
font-size: 20px; font-weight: 700; line-height: 1.1;
@@ -105,6 +104,5 @@ onUnmounted(() => { if (pollId) clearInterval(pollId) })
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-good { color: rgb(var(--v-theme-success)); }
.fc-bad { color: rgb(var(--v-theme-error)); }
</style>
@@ -367,11 +367,6 @@ async function onReprocess() {
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-section-h {
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
}
.fc-token {
display: flex; align-items: center; gap: 4px;
background: rgb(var(--v-theme-surface-light)); border-radius: 6px;
@@ -390,6 +385,4 @@ async function onReprocess() {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-good { color: rgb(var(--v-theme-success)); }
.fc-weak { color: rgb(var(--v-theme-error)); }
</style>
@@ -155,11 +155,6 @@ async function onRecover(it) {
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-section-h {
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
}
.fc-queue { display: flex; gap: 24px; }
.fc-q__n {
font-size: 20px; font-weight: 700; line-height: 1.1;
@@ -169,8 +164,6 @@ async function onRecover(it) {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-good { color: rgb(var(--v-theme-success)); }
.fc-weak { color: rgb(var(--v-theme-error)); }
.fc-defect {
display: flex; align-items: center; gap: 12px;
background: rgb(var(--v-theme-surface-light)); border-radius: 8px;
+97 -93
View File
@@ -95,14 +95,10 @@
<!-- Earned auto-apply -->
<div class="fc-auto mt-6">
<div class="d-flex align-center mb-1" style="gap: 10px;">
<v-icon size="18" color="accent">mdi-lightning-bolt</v-icon>
<span class="fc-section-h">Auto-apply</span>
<v-switch
v-model="autoEnabled" :loading="settingBusy" hide-details density="compact"
color="success" class="ml-auto" @update:model-value="onToggleAuto"
/>
</div>
<SettingToggleRow
v-model="autoEnabled" :loading="settingBusy"
icon="mdi-lightning-bolt" label="Auto-apply" @change="onToggleAuto"
/>
<p class="fc-muted text-body-2 mb-3">
Graduated heads (, with {{ autoMinPosInput }} examples) apply their tag
on their own where they clear {{ Math.round((autoPrecisionInput || 0) * 100) }}%
@@ -111,17 +107,14 @@
</p>
<div class="d-flex mb-3" style="gap: 12px;">
<v-text-field
v-model.number="autoPrecisionInput" label="Precision target"
type="number" min="0.5" max="0.999" step="0.01" density="compact"
hide-details style="max-width: 200px;" :disabled="settingBusy"
<SettingNumberField
v-model="autoPrecisionInput" label="Precision target"
:min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy"
@change="onSaveSettings"
/>
<v-text-field
v-model.number="autoMinPosInput" label="Min examples to fire"
type="number" min="1" density="compact" hide-details
style="max-width: 200px;" :disabled="settingBusy"
@change="onSaveSettings"
<SettingNumberField
v-model="autoMinPosInput" label="Min examples to fire"
:min="1" :disabled="settingBusy" @change="onSaveSettings"
/>
</div>
@@ -161,40 +154,65 @@
<!-- Presentation chrome auto-hide (#141) -->
<div class="fc-auto mt-6">
<div class="d-flex align-center mb-1" style="gap: 10px;">
<v-icon size="18" color="accent">mdi-image-off-outline</v-icon>
<span class="fc-section-h">Hide presentation chrome</span>
<v-switch
v-model="presentationEnabled" :loading="settingBusy" hide-details
density="compact" color="success" class="ml-auto"
@update:model-value="onTogglePresentation"
/>
</div>
<SettingToggleRow
v-model="presentationEnabled" :loading="settingBusy"
icon="mdi-image-off-outline" label="Hide presentation chrome"
@change="onTogglePresentation"
/>
<p class="fc-muted text-body-2 mb-3">
Auto-hide banners and editor screenshots from the gallery once a head has
learned them ( {{ minPositives }} examples) and clears
Auto-hide <code>banner</code> chrome from the gallery once a head has
learned it ( {{ minPositives }} examples) and clears
{{ Math.round((presentationThresholdInput || 0) * 100) }}% confidence.
<code>wip</code> is never auto-hidden. If a hidden image also looks like
real content ( {{ Math.round((presentationConflictInput || 0) * 100) }}%
on a content tag), it's flagged in the Hidden view instead of buried.
Every auto-hide is reversible.
(<code>wip</code> and <code>editor screenshot</code> are handled by the
process auto-tagger below.) If a hidden image also looks like real content
( {{ Math.round((presentationConflictInput || 0) * 100) }}% on a content
tag), it's flagged for review instead of buried. Every auto-hide is reversible.
</p>
<div class="d-flex mb-3" style="gap: 12px;">
<v-text-field
v-model.number="presentationThresholdInput" label="Hide confidence"
type="number" min="0.5" max="0.999" step="0.01" density="compact"
hide-details style="max-width: 200px;" :disabled="settingBusy"
<SettingNumberField
v-model="presentationThresholdInput" label="Hide confidence"
:min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy"
@change="onSavePresentation"
/>
<v-text-field
v-model.number="presentationConflictInput" label="Flag if content ≥"
type="number" min="0" max="1" step="0.05" density="compact"
hide-details style="max-width: 200px;" :disabled="settingBusy"
<SettingNumberField
v-model="presentationConflictInput" label="Flag if content ≥"
:min="0" :max="1" :step="0.05" :disabled="settingBusy"
@change="onSavePresentation"
/>
</div>
</div>
<!-- Process auto-tagging (#1464): wip / editor screenshot -->
<div class="fc-auto mt-6">
<SettingToggleRow
v-model="processEnabled" :loading="settingBusy"
icon="mdi-progress-wrench" label="Auto-tag work-in-progress"
@change="onToggleProcess"
/>
<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;">
<SettingNumberField
v-model="processThresholdInput" label="Tag confidence"
:min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy"
@change="onSaveProcess"
/>
<SettingNumberField
v-model="processConflictInput" label="Flag if content ≥"
:min="0" :max="1" :step="0.05" :disabled="settingBusy"
@change="onSaveProcess"
/>
</div>
</div>
<!-- Performance / tuning -->
<div v-if="metricsConcepts.length" class="mt-5">
<div class="fc-section-h mb-1">How auto-apply is landing</div>
@@ -219,7 +237,7 @@
<td class="fc-r fc-mono">{{ c.n_auto_applied }}</td>
<td class="fc-r fc-mono">{{ c.n_misfires }}</td>
<td class="fc-r fc-mono" :class="rateClass(c.misfire_rate)">
{{ ratePct(c.misfire_rate) }}
{{ pct(c.misfire_rate) }}
</td>
<td class="fc-r fc-mono">{{ c.n_underfires }}</td>
</tr>
@@ -235,6 +253,9 @@ import { toast } from '../../utils/toast.js'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import SettingNumberField from '../common/SettingNumberField.vue'
import SettingToggleRow from '../common/SettingToggleRow.vue'
import { useSettingSave } from '../../composables/useSettingSave.js'
import { useHeadsStore } from '../../stores/heads.js'
import { useMLStore } from '../../stores/ml.js'
@@ -248,7 +269,9 @@ let pollTimer = null
const autoEnabled = ref(false)
const autoPrecisionInput = ref(0.97)
const autoMinPosInput = ref(30)
const settingBusy = ref(false)
// Shared settings-save flow (busy + toast + revert); `settingBusy` gates the
// toggles/fields, `save` returns ok/false for the optimistic-switch revert.
const { busy: settingBusy, save } = useSettingSave(mlSettings.patchSettings)
const autoBusy = ref(false)
const autoStatus = ref(null)
const metricsData = ref(null)
@@ -258,6 +281,9 @@ let autoTimer = null
const presentationEnabled = ref(true)
const presentationThresholdInput = ref(0.90)
const presentationConflictInput = ref(0.50)
const processEnabled = ref(false)
const processThresholdInput = ref(0.90)
const processConflictInput = ref(0.50)
const autoRunning = computed(() => autoStatus.value?.running_id != null)
const lastSweep = computed(() =>
@@ -292,6 +318,9 @@ onMounted(async () => {
presentationEnabled.value = s.presentation_auto_apply_enabled ?? true
presentationThresholdInput.value = s.presentation_auto_apply_threshold ?? 0.90
presentationConflictInput.value = s.presentation_conflict_threshold ?? 0.50
processEnabled.value = s.process_auto_apply_enabled ?? false
processThresholdInput.value = s.process_auto_apply_threshold ?? 0.90
processConflictInput.value = s.process_conflict_threshold ?? 0.50
} catch { /* non-fatal */ }
await refresh()
if (running.value) startPoll()
@@ -352,55 +381,39 @@ function startAutoPoll() {
function stopAutoPoll() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null } }
async function onToggleAuto(val) {
settingBusy.value = true
try {
await mlSettings.patchSettings({ head_auto_apply_enabled: !!val })
toast({ text: val ? 'Auto-apply on' : 'Auto-apply off', type: 'success' })
} catch (e) {
autoEnabled.value = !val // revert the switch
toast({ text: `Could not update: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
const ok = await save({ head_auto_apply_enabled: !!val },
{ successMessage: val ? 'Auto-apply on' : 'Auto-apply off', errorPrefix: 'Could not update' })
if (!ok) autoEnabled.value = !val // revert the switch
}
async function onSaveSettings() {
settingBusy.value = true
try {
await mlSettings.patchSettings({
head_auto_apply_precision: Number(autoPrecisionInput.value),
head_auto_apply_min_positives: Number(autoMinPosInput.value),
})
} catch (e) {
toast({ text: `Could not save: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
await save({
head_auto_apply_precision: Number(autoPrecisionInput.value),
head_auto_apply_min_positives: Number(autoMinPosInput.value),
})
}
async function onTogglePresentation(val) {
settingBusy.value = true
try {
await mlSettings.patchSettings({ presentation_auto_apply_enabled: !!val })
toast({ text: val ? 'Chrome auto-hide on' : 'Chrome auto-hide off', type: 'success' })
} catch (e) {
presentationEnabled.value = !val // revert the switch
toast({ text: `Could not update: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
const ok = await save({ presentation_auto_apply_enabled: !!val },
{ successMessage: val ? 'Chrome auto-hide on' : 'Chrome auto-hide off', errorPrefix: 'Could not update' })
if (!ok) presentationEnabled.value = !val // revert the switch
}
async function onSavePresentation() {
settingBusy.value = true
try {
await mlSettings.patchSettings({
presentation_auto_apply_threshold: Number(presentationThresholdInput.value),
presentation_conflict_threshold: Number(presentationConflictInput.value),
})
} catch (e) {
toast({ text: `Could not save: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
await save({
presentation_auto_apply_threshold: Number(presentationThresholdInput.value),
presentation_conflict_threshold: Number(presentationConflictInput.value),
})
}
async function onToggleProcess(val) {
const ok = await save({ process_auto_apply_enabled: !!val },
{ successMessage: val ? 'WIP auto-tag on' : 'WIP auto-tag off', errorPrefix: 'Could not update' })
if (!ok) processEnabled.value = !val // revert the switch
}
async function onSaveProcess() {
await save({
process_auto_apply_threshold: Number(processThresholdInput.value),
process_conflict_threshold: Number(processConflictInput.value),
})
}
function onPreview() { startSweep(true) }
function onApplyNow() { startSweep(false) }
@@ -426,7 +439,6 @@ function sweepConcepts(run) {
.sort((a, b) => b.applied - a.applied)
}
function sweepTotal(run) { return run?.n_applied ?? 0 }
function ratePct(x) { return x == null ? '—' : `${Math.round(x * 100)}%` }
function rateClass(x) {
if (x == null) return ''
if (x <= 0.03) return 'fc-good'
@@ -457,12 +469,6 @@ function relTime(iso) {
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-section-h {
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
}
.fc-auto {
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 16px;
}
@@ -519,7 +525,5 @@ function relTime(iso) {
background: rgb(var(--v-theme-surface-light));
padding: 1px 6px; border-radius: 999px;
}
.fc-good { color: rgb(var(--v-theme-success)); }
.fc-ok { color: rgb(var(--v-theme-on-surface)); }
.fc-weak { color: rgb(var(--v-theme-error)); }
</style>
@@ -15,7 +15,7 @@
being dropped as duplicates;</strong> raise it to collapse more
look-alikes. Applies to new imports.
</div>
<v-row align="center" no-gutters>
<v-row no-gutters class="align-center">
<v-col cols="12" sm="9">
<v-slider
v-model="local.phash_threshold"
@@ -79,6 +79,46 @@
</v-col>
</v-row>
<v-divider class="my-5" />
<!-- Title-based WIP auto-tagging (task #1458). The switch gates the LIVE
import hook; the button runs the one-off back-catalogue scan (an
explicit action it is deliberately not a scheduled sweep so it can't
silently re-apply a WIP tag you removed by hand). -->
<div class="fc-wip">
<div class="fc-wip__title">WIP auto-tagging</div>
<v-switch
v-model="local.wip_title_tagging_enabled"
label="Tag work-in-progress from post titles"
density="compact" hide-details color="primary" @change="save"
/>
<div class="fc-help mb-3">
When a post's title says <strong>WIP</strong> or
<strong>work in progress</strong>, new imports get the
<code>wip</code> tag automatically keeping unfinished pieces out of
the Explore browse. Applies to new imports; run the scan below to catch
posts already in your library.
</div>
<v-switch
v-model="local.wip_soft_title_tagging_enabled"
label="Also tag “sketch” / “doodle” titles (lower precision)"
density="compact" hide-details color="primary" @change="save"
/>
<div class="fc-help mb-3">
Extends the above to softer cues (<code>sketch</code>, <code>doodle</code>,
<code>scribble</code>). These stay <strong>visible</strong> and never train
the tagging model a daily audit flags any that actually look like finished
art for review. Off by default.
</div>
<v-btn
variant="tonal" color="primary" size="small"
:loading="store.wipScanBusy" prepend-icon="mdi-magnify"
@click="store.scanWipTitles()"
>
Scan existing posts for WIP titles
</v-btn>
</div>
<v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable>
{{ store.settingsError }}
</v-alert>
@@ -109,6 +149,8 @@ const local = reactive({
skip_transparent: false, transparency_threshold: 0.9,
skip_single_color: false, single_color_threshold: 0.95,
phash_threshold: 10,
wip_title_tagging_enabled: true,
wip_soft_title_tagging_enabled: false,
})
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
@@ -124,11 +166,18 @@ async function save() {
color: rgb(var(--v-theme-on-surface-variant));
margin-top: 2px;
}
.fc-phash__title {
.fc-phash__title,
.fc-wip__title {
font-size: 0.95rem;
font-weight: 600;
color: rgb(var(--v-theme-on-surface));
}
.fc-wip code {
font-size: 0.85em;
padding: 1px 4px;
border-radius: 3px;
background: rgb(var(--v-theme-surface-variant));
}
/* Headroom so the tick labels (Exact/Strict/Default/Loose) aren't clipped. */
.fc-phash__slider { margin-bottom: 18px; }
</style>
@@ -39,13 +39,14 @@
import { toast } from '../../utils/toast.js'
import { onMounted, ref } from 'vue'
import { useMLStore } from '../../stores/ml.js'
import { useSettingSave } from '../../composables/useSettingSave.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue'
const store = useMLStore()
const { busy: saving, save } = useSettingSave(store.patchSettings)
const busy = ref(false)
const done = ref(false)
const enabled = ref(true)
const saving = ref(false)
onMounted(async () => {
try {
await store.loadSettings()
@@ -55,21 +56,12 @@ onMounted(async () => {
} catch { /* non-fatal */ }
})
async function onToggle() {
saving.value = true
try {
await store.patchSettings({ cpu_embed_enabled: enabled.value })
toast({
text: enabled.value
? 'CPU embedding on — imports queue embeds for the ml-worker'
: 'CPU embedding off — the GPU embed backfill owns whole-image embeds',
type: 'success',
})
} catch (e) {
toast({ text: `Could not save: ${e.message}`, type: 'error' })
enabled.value = !enabled.value
} finally {
saving.value = false
}
const ok = await save({ cpu_embed_enabled: enabled.value }, {
successMessage: enabled.value
? 'CPU embedding on — imports queue embeds for the ml-worker'
: 'CPU embedding off — the GPU embed backfill owns whole-image embeds',
})
if (!ok) enabled.value = !enabled.value
}
async function run() {
busy.value = true
@@ -80,5 +72,4 @@ async function run() {
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
</style>
@@ -24,6 +24,7 @@
the CPU fallback.
</p>
<div class="fc-tile-stack">
<VideoEmbeddingCard />
<GpuAgentCard />
<GpuTriageCard />
<MLBackfillCard />
@@ -36,7 +37,6 @@
Suggestion thresholds, trained heads and tag aliases.
</p>
<div class="fc-tile-stack">
<MLThresholdSliders />
<CropProposersCard />
<HeadsCard />
<AliasTable />
@@ -77,7 +77,7 @@ import ArchiveReextractCard from './ArchiveReextractCard.vue'
import MissingFileRepairCard from './MissingFileRepairCard.vue'
import GpuTriageCard from './GpuTriageCard.vue'
import DbMaintenanceCard from './DbMaintenanceCard.vue'
import MLThresholdSliders from './MLThresholdSliders.vue'
import VideoEmbeddingCard from './VideoEmbeddingCard.vue'
import CropProposersCard from './CropProposersCard.vue'
import HeadsCard from './HeadsCard.vue'
import GpuAgentCard from './GpuAgentCard.vue'
@@ -1,5 +1,5 @@
<template>
<v-row dense>
<v-row density="compact">
<v-col v-for="card in cards" :key="card.label" cols="12" sm="6" md="4" lg="3" xl="2">
<v-card class="fc-stat">
<v-card-text>
@@ -12,17 +12,17 @@
</div>
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model.number="local.video_frame_interval_seconds"
label="Frame interval (s)" type="number" min="0.5" step="0.5"
density="comfortable" hide-details @change="save"
<SettingNumberField
v-model="local.video_frame_interval_seconds"
label="Frame interval (s)" :min="0.5" :step="0.5"
density="comfortable" max-width="none" @change="onSave"
/>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model.number="local.video_max_frames"
label="Max frames" type="number" min="1" step="1"
density="comfortable" hide-details @change="save"
<SettingNumberField
v-model="local.video_max_frames"
label="Max frames" :min="1" :step="1"
density="comfortable" max-width="none" @change="onSave"
/>
</v-col>
</v-row>
@@ -32,21 +32,23 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { reactive, watch } from 'vue'
import { useMLStore } from '../../stores/ml.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import SettingNumberField from '../common/SettingNumberField.vue'
import { useSettingSave } from '../../composables/useSettingSave.js'
const store = useMLStore()
const { save } = useSettingSave(store.patchSettings)
const local = reactive({})
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
async function save() {
const patch = {
video_frame_interval_seconds: local.video_frame_interval_seconds,
video_max_frames: local.video_max_frames
}
try { await store.patchSettings(patch) }
catch (e) { toast({ text: e.message, type: 'error' }) }
// SettingNumberField clamps interval to 0.5 and max-frames to 1 before this
// fires, so an out-of-range value never reaches the API.
function onSave() {
save({
video_frame_interval_seconds: Number(local.video_frame_interval_seconds),
video_max_frames: Number(local.video_max_frames),
})
}
</script>
@@ -0,0 +1,33 @@
import { ref } from 'vue'
import { toast } from '../utils/toast.js'
// The shared "persist a settings patch" flow for the ML settings cards. Flips a
// busy flag, calls the store's patch (which rethrows on failure), toasts
// success/error, and returns true/false so a toggle handler can revert its
// optimistic switch on failure. Centralises the try/catch/toast the cards each
// hand-rolled (HeadsCard x6, CropProposersCard, MLBackfillCard) — and where the
// threshold-clamp drifted; the clamp now lives in <SettingNumberField>.
//
// Pass the store's patch fn, e.g. useSettingSave(ml.patchSettings).
export function useSettingSave(patchFn) {
const busy = ref(false)
// opts.successMessage — toast on success (toggles announce their new state;
// silent field-saves omit it). opts.errorPrefix — the failure toast prefix
// ("Could not save" default; toggles used "Could not update").
async function save(patch, { successMessage = '', errorPrefix = 'Could not save' } = {}) {
busy.value = true
try {
await patchFn(patch)
if (successMessage) toast({ text: successMessage, type: 'success' })
return true
} catch (e) {
toast({ text: `${errorPrefix}: ${e.message}`, type: 'error' })
return false
} finally {
busy.value = false
}
}
return { busy, save }
}
+5 -5
View File
@@ -20,7 +20,7 @@ const routes = [
// FC-2: image backbone
{ path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase', navOrder: 10 } },
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery', navOrder: 20 } },
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery', navOrder: 20, stickyChrome: true } },
// Explore: a 3-pane tagging workspace — walk an image's visual neighbours
// (left) while tagging the focused image (center viewer + modal-parity tag
// rail). Optional anchor param — the bare /explore nav entry SEEDS a random
@@ -29,11 +29,11 @@ const routes = [
// Browse hub (operator-asked 2026-06-09): Posts / Artists / Tags as tabs —
// the three "browse the library by an axis" surfaces. One nav entry; the old
// standalone paths redirect into the matching tab (below).
{ path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse', navOrder: 30 } },
{ path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse', navOrder: 30, stickyChrome: true } },
// Artist detail — no meta.title (reached by clicking an artist, not nav).
{ path: '/artist/:slug', name: 'artist', component: ArtistView },
// Series browse — a nav entry (meta.title).
{ path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series', navOrder: 40 } },
{ path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series', navOrder: 40, stickyChrome: true } },
// Series management — no meta.title (reached from a series card/tag).
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
// Series reader — immersive (no top nav, no meta.title).
@@ -41,10 +41,10 @@ const routes = [
// FC-3: subscription backbone — purely management (sources/downloads),
// distinct from the Browse hub.
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions', navOrder: 50 } },
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions', navOrder: 50, stickyChrome: true } },
// Settings — config, pinned to the right of the nav (TopNav special-cases it).
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings', stickyChrome: true } },
// The old standalone paths now redirect into the Browse hub, preserving any
// deep-link query (e.g. /posts?post_id=N → /browse?tab=posts&post_id=N). The
+21 -3
View File
@@ -25,6 +25,10 @@ export const useExploreStore = defineStore('explore', () => {
const cursor = ref(-1)
const loading = ref(false)
const error = ref(null)
// Reach (#1476): how far the walk reaches past the anchor's immediate cluster.
// 0 = nearest (can get stuck in a dense signature); ~0.4 default mixes in
// mid-far escape routes so the walk diversifies without hitting "Random image".
const reach = ref(0.4)
const inflight = useInflightToken()
@@ -46,7 +50,13 @@ export const useExploreStore = defineStore('explore', () => {
const body = await api.get('/api/gallery/similar', {
// exclude_wip: keep work-in-progress out of the Explore rabbit-hole
// (the gallery's own "similar" button still shows it) — operator 2026-07-08.
params: { similar_to: numId, limit: NEIGHBOR_LIMIT, exclude_wip: 1 },
// reach + exclude_ids (#1476): reach past the dense cluster + never re-serve
// an already-walked image, so the walk keeps moving instead of getting stuck.
params: {
similar_to: numId, limit: NEIGHBOR_LIMIT, exclude_wip: 1,
reach: reach.value,
exclude_ids: breadcrumb.value.map((c) => c.id).join(','),
},
})
if (!t.isCurrent()) return
neighbors.value = body.images || []
@@ -113,6 +123,14 @@ export const useExploreStore = defineStore('explore', () => {
loading.value = false
}
// Change how far the walk reaches and re-fetch the current anchor's neighbours
// with the new setting (the anchor + trail are unchanged — only the grid varies).
function setReach (v) {
reach.value = Math.max(0, Math.min(1, Number(v)))
const id = anchor.value?.id
if (id != null) anchorOn(id)
}
// --- TagPanel "host" surface ---------------------------------------------
// The anchor IS the current image (same /api/gallery/image/<id> payload the
// modal uses), so these mirror the modal store's tag-CRUD, targeting the
@@ -189,8 +207,8 @@ export const useExploreStore = defineStore('explore', () => {
function close () {}
return {
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT,
anchorOn, reset, backTarget, forwardTarget,
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT, reach,
anchorOn, reset, backTarget, forwardTarget, setReach,
// host surface
current, currentImageId,
reloadTags, addExistingTag, removeTag, createAndAdd, close,
+20 -2
View File
@@ -16,6 +16,7 @@ export const useImportStore = defineStore('import', () => {
const settings = ref(null)
const settingsLoading = ref(false)
const settingsError = ref(null)
const wipScanBusy = ref(false)
async function loadSettings() {
settingsLoading.value = true
@@ -40,8 +41,25 @@ export const useImportStore = defineStore('import', () => {
}
}
// Enqueue the back-catalogue WIP-title scan (task #1458). New imports are
// tagged live; this catches posts already in the library. Fire-and-forget —
// the sweep runs on the maintenance worker and its run shows in Activity.
async function scanWipTitles() {
wipScanBusy.value = true
try {
const r = await api.post('/api/settings/wip-title/scan')
toast({ text: 'Scanning existing posts for WIP titles…', type: 'success' })
return r
} catch (e) {
toast({ text: `WIP scan failed: ${e.message}`, type: 'error' })
throw e
} finally {
wipScanBusy.value = false
}
}
return {
settings, settingsLoading, settingsError,
loadSettings, patchSettings,
settings, settingsLoading, settingsError, wipScanBusy,
loadSettings, patchSettings, scanWipTitles,
}
})
+88
View File
@@ -39,3 +39,91 @@
deliberately not used here. `.fc-muted` is a custom class Vuetify never
emits, so no specificity/reorder fight — no !important needed. */
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
/* Section sub-heading in settings cards (DRY pass #161): was redefined
identically in 4 cards, and TranslationCard used the class with NO local def
so its section headers rendered unstyled. Now one global utility. */
.fc-section-h {
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
}
/* Status text colours (DRY pass #161): fc-good = success, fc-weak = error,
consolidated from the GPU / heads cards. fc-ok is intentionally NOT global —
it means on-surface in HeadsCard but success in QueuesTable. */
.fc-good { color: rgb(var(--v-theme-success)); }
.fc-weak { color: rgb(var(--v-theme-error)); }
/* 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.
Background uses the theme surface token so content scrolls cleanly
under it (matches SettingsView's sticky tabs). -->
<div class="fc-browse__head">
<div class="fc-browse__head fc-chrome-continues">
<v-container fluid class="py-0">
<!-- Tabs and search share one row: the axis switcher on the left, the
search field + active-scope chips on the right (operator-asked
@@ -154,9 +154,10 @@ function clearFilter(key) {
<style scoped>
.fc-browse__head {
position: sticky;
top: 64px; /* directly under AppShell's 64px sticky TopNav */
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
z-index: 4;
background: rgb(var(--v-theme-surface));
/* Background is the shared .fc-chrome-continues fade — it continues the nav's
gradient instead of a solid surface band (operator 2026-07-13). */
}
.fc-browse__bar {
display: flex;
+33 -3
View File
@@ -31,6 +31,20 @@
<img :src="c.thumbnail_url" alt="" loading="lazy" />
</button>
<div class="fc-ex__trail-actions">
<!-- Reach (#1476): how far each step reaches past the anchor's cluster.
Raise it to break out of a dense signature without hitting Random. -->
<div
class="fc-ex__reach"
title="How far each step reaches — raise it to escape a dense cluster without going fully random"
>
<v-icon size="16" color="accent">mdi-map-marker-distance</v-icon>
<v-slider
:model-value="store.reach" @end="store.setReach"
:min="0" :max="1" :step="0.2" hide-details density="compact"
color="accent" class="fc-ex__reach-slider"
/>
<span class="fc-muted fc-ex__reach-label">{{ reachLabel }}</span>
</div>
<!-- Active retrain right where you tag: fold the +/- you just gave
into the heads without a trip to Settings (the nightly beat is the
passive cadence). -->
@@ -153,6 +167,12 @@ const modal = useModalStore()
const anchorId = computed(() => route.params.imageId || null)
const isVideo = computed(() => !!store.anchor?.mime?.startsWith('video/'))
const reachLabel = computed(() => {
const r = store.reach
if (r <= 0.15) return 'Near'
if (r <= 0.55) return 'Varied'
return 'Far'
})
// #1206: hovering a suggestion in the rail highlights the crop it came from on
// the anchor image (same provide/inject as the modal viewer).
@@ -264,12 +284,14 @@ onUnmounted(() => {
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
/* Full-height workspace under the sticky top nav. */
/* Full-height workspace under the sticky top nav. --fc-nav-h is the nav's REAL
measured height (set by TopNav) — a hardcoded 64px here overflowed the
viewport under Vuetify 4's taller nav and tucked the breadcrumb under it
(#1481). Panes scroll internally, so an exact fit keeps everything on screen. */
.fc-ex {
display: flex; flex-direction: column;
height: calc(100vh - 64px);
height: calc(100vh - var(--fc-nav-h, 64px));
min-height: 0;
}
@@ -296,6 +318,14 @@ onUnmounted(() => {
.fc-ex__trail-actions {
margin-left: auto; display: flex; align-items: center; gap: 4px; flex: 0 0 auto;
}
.fc-ex__reach {
display: flex; align-items: center; gap: 6px;
margin-right: 8px;
}
.fc-ex__reach-slider { width: 96px; }
.fc-ex__reach-label {
font-size: 12px; min-width: 44px; text-align: left;
}
/* The three panes fill the remaining height; each scrolls on its own.
grid-template-rows: minmax(0, 1fr) BOUNDS the single row to the container
+5 -4
View File
@@ -4,7 +4,7 @@
the 64px TopNav (operator-asked 2026-06-12), so the axis switcher and
search/sort stay reachable on a long grid. The controls can't sit
inside v-window (it clips sticky children), so they're hoisted here. -->
<div class="fc-series__head">
<div class="fc-series__head fc-chrome-continues">
<v-tabs v-model="tab" density="compact">
<v-tab value="browse">Browse</v-tab>
<v-tab value="suggestions">
@@ -294,12 +294,13 @@ onMounted(() => {
<style scoped>
/* Sticky header (tabs + active-tab controls) pinned under the 64px TopNav, so
content scrolls cleanly beneath it. Surface bg matches SettingsView. */
content scrolls cleanly beneath it. Background is the shared
.fc-chrome-continues fade — continues the nav's gradient rather than a solid
band (operator 2026-07-13). */
.fc-series__head {
position: sticky;
top: 64px;
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
z-index: 4;
background: rgb(var(--v-theme-surface));
padding-bottom: 12px;
}
.fc-series-browse__controls {
+4 -6
View File
@@ -7,13 +7,11 @@
<!-- Sticky tabs: operator-flagged 2026-05-25 long Import / Maintenance
panels pushed the tab strip out of the viewport, forcing a scroll-
to-top just to change tab. AppShell's TopNav is 64px sticky, so the
tab strip lives directly under it. Background uses the theme surface
token so it visually merges with the page rather than the
translucent v-tabs default. -->
tab strip lives directly under it. The .fc-chrome-continues fade
continues the nav's gradient across the strip (operator 2026-07-13). -->
<v-tabs
v-model="tab" color="accent" class="mb-4"
style="position: sticky; top: 64px; z-index: 4;
background: rgb(var(--v-theme-surface));"
v-model="tab" color="accent" class="mb-4 fc-chrome-continues"
style="position: sticky; top: var(--fc-nav-h, 64px); z-index: 4;"
>
<v-tab value="overview">Overview</v-tab>
<v-tab value="activity">Activity</v-tab>
+7 -4
View File
@@ -5,7 +5,7 @@
align-tabs="start"
color="accent"
density="compact"
class="fc-subs-tabs"
class="fc-subs-tabs fc-chrome-continues"
>
<v-tab value="subscriptions">
<v-icon start>mdi-account-multiple-check</v-icon>
@@ -55,15 +55,18 @@ const { tab } = useTabQuery(VALID_TABS, 'subscriptions')
/* Fixed-height hub: the tabs (and each tab's sticky control bar) stay
put while ONLY the tab content scrolls — previously the whole view
scrolled instead of just the subscription list (operator-flagged
2026-05-28). 64px = the TopNav height (AppShell .fc-content pad-top). */
height: calc(100vh - 64px);
2026-05-28). --fc-nav-h = the TopNav's real measured height (#1481). */
height: calc(100vh - var(--fc-nav-h, 64px));
display: flex;
flex-direction: column;
overflow: hidden;
}
.fc-subs-tabs {
flex: 0 0 auto;
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
/* Cancel the shell's pt-2 so the tabs sit flush under the 64px nav, letting
the .fc-chrome-continues fade read as one gradient with it (operator
2026-07-13). The fade replaces the old border-bottom separator. */
margin-top: -8px;
}
.fc-subs-window {
flex: 1 1 auto;
-1
View File
@@ -349,7 +349,6 @@ async function onDeleteTagConfirm() {
.fc-tags__sentinel {
display: flex; justify-content: center; padding: 32px 0; min-height: 60px;
}
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-merge-preview {
padding: 10px 12px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
+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
async def test_scroll_hides_presentation_chrome_by_default(db):
# banner / editor screenshot (presentation system tags) are hidden from the
# default gallery; wip (also a system tag) is NOT — it's real, in-progress
# art (milestone 141). Seeded system tags survive the harness TRUNCATE.
imgs = await _seed_images(db, 3, sha_prefix="p")
# banner (chrome) is hidden from the default gallery; wip AND editor screenshot
# (the PROCESS system tags) are NOT — they're real art / process shots that stay
# visible (milestone 141 + #1464). Seeded system tags survive the harness TRUNCATE.
imgs = await _seed_images(db, 4, sha_prefix="p")
banner = await _system_tag(db, "banner")
wip = await _system_tag(db, "wip")
editor = await _system_tag(db, "editor screenshot")
await db.execute(image_tag.insert().values(
image_record_id=imgs[0].id, tag_id=banner.id, source="manual"))
await db.execute(image_tag.insert().values(
image_record_id=imgs[1].id, tag_id=wip.id, source="manual"))
await db.execute(image_tag.insert().values(
image_record_id=imgs[3].id, tag_id=editor.id, source="manual"))
await db.flush()
svc = GalleryService(db)
# Default: the banner image is hidden; the wip image + the plain image stay.
# Default: only the banner image is hidden; wip + editor + plain all stay.
default_ids = {i.id for i in (await svc.scroll(cursor=None, limit=10)).images}
assert imgs[0].id not in default_ids # banner hidden
assert imgs[1].id in default_ids # wip visible
assert imgs[2].id in default_ids # plain visible
assert imgs[3].id in default_ids # editor screenshot visible (#1464)
# include_hidden surfaces the banner image (the Hidden view).
shown = {i.id for i in (
+17
View File
@@ -170,6 +170,23 @@ async def test_similar_respects_limit(db):
assert len(res) == 2
@pytest.mark.asyncio
async def test_similar_exclude_ids_drops_walked(db):
"""Explore passes its breadcrumb as exclude_ids so already-walked images
aren't re-served as neighbours (#1476). reach>0 runs cleanly too (small pool
→ the sampler passes through)."""
src = await _img(db, 1, _vec(1, 0))
walked = await _img(db, 2, _vec(1, 0.05))
fresh = await _img(db, 3, _vec(1, 0.3))
svc = GalleryService(db)
res = await svc.similar(src.id, limit=10, exclude_ids=[walked.id])
ids = {i.id for i in res}
assert walked.id not in ids
assert fresh.id in ids
res_reach = await svc.similar(src.id, limit=10, reach=1.0)
assert fresh.id in {i.id for i in res_reach}
# --- API ---
@pytest.mark.asyncio
+59
View File
@@ -0,0 +1,59 @@
"""Shared ML helpers extracted in the DRY pass (milestone #161). These pin the
single sources the auto-apply sweeps now trust, so a future edit can't silently
drift them: `_applied_or_rejected` is the skip-set used by auto_apply_sweep,
system_tag_auto_apply_sweep (heads.py) and scheduled_ccip_auto_apply (tasks/ml.py);
`_sigmoid` is the head score→prob transform used at every scoring site."""
import pytest
from backend.app.models import ImageRecord, Tag, TagKind, TagSuggestionRejection
from backend.app.models.tag import image_tag
from backend.app.services.ml.training_data import _applied_or_rejected
def test_sigmoid_matches_naive_form():
import numpy as np
from backend.app.services.ml.heads import _sigmoid
z = np.array([-3.0, -0.5, 0.0, 1.5, 12.0], dtype=np.float32)
assert np.allclose(_sigmoid(z, np), 1.0 / (1.0 + np.exp(-z)))
assert float(_sigmoid(np.array([0.0]), np)[0]) == pytest.approx(0.5)
@pytest.mark.integration
def test_applied_or_rejected_unions_applied_any_source_and_rejected(db_sync):
a = Tag(name="dry-helper-a", kind=TagKind.general)
b = Tag(name="dry-helper-b", kind=TagKind.general)
db_sync.add_all([a, b])
db_sync.flush()
imgs = []
for i in range(5):
img = ImageRecord(
path=f"/images/dryhelp{i}.jpg", sha256=f"{i:064d}", size_bytes=1,
mime="image/jpeg", width=1, height=1, origin="imported_filesystem",
integrity_status="unknown", siglip_embedding=[0.0] * 1152,
)
db_sync.add(img)
imgs.append(img)
db_sync.flush()
# tag a: applied manually (img0), applied by an AUTO source (img1), rejected (img2).
db_sync.execute(image_tag.insert().values(
image_record_id=imgs[0].id, tag_id=a.id, source="manual"))
db_sync.execute(image_tag.insert().values(
image_record_id=imgs[1].id, tag_id=a.id, source="head_auto"))
db_sync.add(TagSuggestionRejection(image_record_id=imgs[2].id, tag_id=a.id))
# tag b: applied to img3 only.
db_sync.execute(image_tag.insert().values(
image_record_id=imgs[3].id, tag_id=b.id, source="manual"))
db_sync.flush()
skip = _applied_or_rejected(db_sync, [a.id, b.id])
# Applied-under-ANY-source (manual + head_auto) rejected, kept per-tag; the
# untouched image (img4) appears under neither tag.
assert skip[a.id] == {imgs[0].id, imgs[1].id, imgs[2].id}
assert skip[b.id] == {imgs[3].id}
assert imgs[4].id not in skip[a.id]
assert imgs[4].id not in skip[b.id]
+6 -6
View File
@@ -15,7 +15,7 @@ from backend.app.models import (
from backend.app.models.tag import image_tag
from backend.app.services.ml.heads import (
auto_apply_sweep,
presentation_auto_apply_sweep,
system_tag_auto_apply_sweep,
)
pytestmark = pytest.mark.integration
@@ -70,7 +70,7 @@ def test_presentation_sweep_hides_chrome(db_sync):
_head(db_sync, banner.id, 0, weight=3.0)
img = _img(db_sync, "a" * 64, _emb(0))
db_sync.commit()
res = presentation_auto_apply_sweep(db_sync)
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
assert res["n_applied"] == 1
assert _source(db_sync, img.id, banner.id) == "presentation_auto"
@@ -86,7 +86,7 @@ def test_presentation_sweep_hard_skips_valued_image(db_sync):
db_sync.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=content.id, source="manual"))
db_sync.commit()
res = presentation_auto_apply_sweep(db_sync)
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
assert res["n_applied"] == 0
assert _source(db_sync, img.id, banner.id) is None
@@ -102,7 +102,7 @@ def test_presentation_sweep_flags_conflict(db_sync):
_head(db_sync, content.id, 0, weight=1.0) # content head also fires
img = _img(db_sync, "c" * 64, _emb(0))
db_sync.commit()
res = presentation_auto_apply_sweep(db_sync)
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
assert res["n_applied"] == 1
assert res["n_flagged"] == 1
assert _source(db_sync, img.id, banner.id) == "presentation_auto"
@@ -123,7 +123,7 @@ def test_presentation_sweep_disabled_is_noop(db_sync):
_head(db_sync, banner.id, 0, weight=3.0)
img = _img(db_sync, "d" * 64, _emb(0))
db_sync.commit()
res = presentation_auto_apply_sweep(db_sync)
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
assert res["n_applied"] == 0
assert _source(db_sync, img.id, banner.id) is None
@@ -134,7 +134,7 @@ def test_presentation_sweep_ignores_wip(db_sync):
_head(db_sync, wip.id, 0, weight=3.0)
img = _img(db_sync, "e" * 64, _emb(0))
db_sync.commit()
res = presentation_auto_apply_sweep(db_sync)
res = system_tag_auto_apply_sweep(db_sync, mode="chrome")
assert res["n_applied"] == 0
assert _source(db_sync, img.id, wip.id) is None
+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
+63
View File
@@ -0,0 +1,63 @@
"""recover_stalled_head_training_runs + recover_stalled_head_auto_apply_runs share
one helper (_recover_stalled_runs, DRY pass #161). These pin BOTH wrappers so the
shared source stays correct: a 'running' row with no progress past the stall
threshold flips to 'error'; a fresh 'running' row is left alone."""
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import select
from backend.app.models import HeadAutoApplyRun, HeadTrainingRun
pytestmark = pytest.mark.integration
def test_recover_stalled_head_training_runs_flips_stalled_keeps_fresh(db_sync):
from backend.app.tasks.maintenance import recover_stalled_head_training_runs
stale = HeadTrainingRun(
params={}, status="running",
last_progress_at=datetime.now(UTC) - timedelta(days=1),
)
fresh = HeadTrainingRun(
params={}, status="running", last_progress_at=datetime.now(UTC),
)
db_sync.add_all([stale, fresh])
db_sync.commit()
stale_id, fresh_id = stale.id, fresh.id
assert recover_stalled_head_training_runs.apply().get() == 1
db_sync.expire_all()
assert db_sync.execute(
select(HeadTrainingRun.status).where(HeadTrainingRun.id == stale_id)
).scalar_one() == "error"
assert db_sync.execute(
select(HeadTrainingRun.status).where(HeadTrainingRun.id == fresh_id)
).scalar_one() == "running"
def test_recover_stalled_head_auto_apply_runs_flips_stalled_keeps_fresh(db_sync):
from backend.app.tasks.maintenance import recover_stalled_head_auto_apply_runs
stale = HeadAutoApplyRun(
dry_run=False, params={}, status="running",
last_progress_at=datetime.now(UTC) - timedelta(days=1),
)
fresh = HeadAutoApplyRun(
dry_run=False, params={}, status="running",
last_progress_at=datetime.now(UTC),
)
db_sync.add_all([stale, fresh])
db_sync.commit()
stale_id, fresh_id = stale.id, fresh.id
assert recover_stalled_head_auto_apply_runs.apply().get() == 1
db_sync.expire_all()
assert db_sync.execute(
select(HeadAutoApplyRun.status).where(HeadAutoApplyRun.id == stale_id)
).scalar_one() == "error"
assert db_sync.execute(
select(HeadAutoApplyRun.status).where(HeadAutoApplyRun.id == fresh_id)
).scalar_one() == "running"
+76
View File
@@ -0,0 +1,76 @@
"""Title-based WIP matcher (task #1458) — pure unit tests for
``matches_wip_title``, the precision-first heuristic that decides whether a post
title explicitly declares work-in-progress. No DB, no network.
Precision matters more than recall here: a false positive applies the ``wip``
system tag, which HIDES a finished post from the Explore browse — so the
negative cases (substrings like ``swipe`` / ``wiped``) are the load-bearing ones.
"""
import pytest
from backend.app.services.wip_title import matches_soft_wip_title, matches_wip_title
@pytest.mark.parametrize("title", [
"WIP",
"wip",
"WiP",
"Nami Heroines - WIP Part 1", # the real-world example from the gate work
"sketch (WIP)",
"[WIP] new piece",
"WIP: colour test",
"cool art WIP2", # trailing digit = "WIP part 2", still WIP
"W.I.P.",
"W.I.P",
"work in progress",
"Work In Progress",
"commission work-in-progress",
"big_project_work_in_progress",
"final touches, wip",
])
def test_matches_positive(title):
assert matches_wip_title(title) is True
@pytest.mark.parametrize("title", [
None,
"",
"a quick swipe left", # 'wip' inside swipe — must NOT match
"she wiped the counter", # wiped
"wiping down the desk", # wiping
"unwiped surface",
"progressive rock cover", # 'progress' inside progressive, no 'work in'
"workin on it", # not the full phrase
"finished at last",
"Kawips diner", # 'wip' mid-word
"swipright",
"quick sketch of Nami", # soft cue — NOT a HARD WIP match
])
def test_matches_negative(title):
assert matches_wip_title(title) is False
@pytest.mark.parametrize("title", [
"quick sketch",
"Nami sketch",
"morning doodle",
"some doodles",
"sketches from today",
"a little scribble",
"SKETCH",
])
def test_soft_matches_positive(title):
assert matches_soft_wip_title(title) is True
@pytest.mark.parametrize("title", [
None,
"",
"sketchbook tour", # 'sketch' inside sketchbook — must NOT match
"kadoodle mascot", # 'doodle' mid-word
"the final piece",
"WIP", # a HARD cue is not a SOFT cue
"prescribed colours", # 'scrib' inside prescribed — must NOT match
])
def test_soft_matches_negative(title):
assert matches_soft_wip_title(title) is False
+121
View File
@@ -0,0 +1,121 @@
"""Title-based WIP auto-tagging (task #1458) — integration tests for the apply
helpers and the operator-triggered backfill sweep against a real DB. The
importer's live hook is a thin call to these same tested pieces (matcher +
apply), so the DB-facing behaviour is covered here.
"""
import pytest
from sqlalchemy import select
from backend.app.celery_app import celery
from backend.app.models import Artist, ImageProvenance, ImageRecord, Post, Source
from backend.app.models.tag import image_tag
from backend.app.services.wip_title import (
WIP_TITLE_SOFT_SOURCE,
apply_wip_image_tags,
resolve_wip_tag_id,
)
pytestmark = pytest.mark.integration
_N = 0
@pytest.fixture(autouse=True)
def eager():
celery.conf.task_always_eager = True
yield
celery.conf.task_always_eager = False
def _img(db_sync):
global _N
_N += 1
rec = ImageRecord(
path=f"/images/w/{_N}.jpg", sha256=f"w{_N:063d}",
size_bytes=1, mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
)
db_sync.add(rec)
db_sync.flush()
return rec
def _post(db_sync, *, title, slug, ext):
a = Artist(name=slug.upper(), slug=slug)
db_sync.add(a)
db_sync.flush()
s = Source(artist_id=a.id, platform="patreon", url=f"https://patreon.test/{slug}")
db_sync.add(s)
db_sync.flush()
p = Post(source_id=s.id, artist_id=a.id, external_post_id=ext, post_title=title)
db_sync.add(p)
db_sync.flush()
return s, p
def _link(db_sync, rec, post, source):
db_sync.add(ImageProvenance(
image_record_id=rec.id, post_id=post.id, source_id=source.id,
))
db_sync.flush()
def _wip_source(db_sync, image_id, tag_id):
return db_sync.execute(
select(image_tag.c.source).where(
image_tag.c.image_record_id == image_id,
image_tag.c.tag_id == tag_id,
)
).scalar_one_or_none()
def test_resolve_wip_tag_id_present(db_sync):
# The `wip` system tag is seeded by migration 0075 and restored between tests.
assert resolve_wip_tag_id(db_sync) is not None
def test_apply_is_idempotent_and_stamps_source(db_sync):
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) == 1
# Second apply is a no-op (ON CONFLICT DO NOTHING) — never disturbs the row.
assert apply_wip_image_tags(db_sync, [rec.id], tag_id) == 0
assert _wip_source(db_sync, rec.id, tag_id) == "wip_title"
def test_backfill_tags_only_wip_titled_posts(db_sync):
from backend.app.tasks.maintenance import backfill_wip_title_tags
tag_id = resolve_wip_tag_id(db_sync)
wip_rec = _img(db_sync)
plain_rec = _img(db_sync)
swipe_rec = _img(db_sync)
s1, wip_post = _post(db_sync, title="Cool piece - WIP Part 1", slug="wipy", ext="1")
s2, plain_post = _post(db_sync, title="Finished commission", slug="doney", ext="2")
s3, swipe_post = _post(db_sync, title="a quick swipe", slug="swipey", ext="3")
_link(db_sync, wip_rec, wip_post, s1)
_link(db_sync, plain_rec, plain_post, s2)
_link(db_sync, swipe_rec, swipe_post, s3) # 'swipe' must NOT trip the matcher
db_sync.commit()
assert backfill_wip_title_tags.apply().get() == 1
assert _wip_source(db_sync, wip_rec.id, tag_id) == "wip_title"
assert _wip_source(db_sync, plain_rec.id, tag_id) is None
assert _wip_source(db_sync, swipe_rec.id, tag_id) is None
# Idempotent: a second sweep finds the tag already present and applies nothing.
assert backfill_wip_title_tags.apply().get() == 0
def test_apply_soft_source_stamps_wip_title_soft(db_sync):
# The soft tier (#1474) stamps a distinct provisional source.
tag_id = resolve_wip_tag_id(db_sync)
rec = _img(db_sync)
db_sync.commit()
assert apply_wip_image_tags(
db_sync, [rec.id], tag_id, source=WIP_TITLE_SOFT_SOURCE
) == 1
assert _wip_source(db_sync, rec.id, tag_id) == "wip_title_soft"